From 0fafaab982307231e5b9b62170f7c75e0696de8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:23:31 +0000 Subject: [PATCH 01/52] Bump @tailwindcss/vite from 4.3.0 to 4.3.3 in /frontend Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.3.0 to 4.3.3. - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite) --- updated-dependencies: - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 214 ++++++++++++++++++++++++------------- frontend/package.json | 2 +- 2 files changed, 141 insertions(+), 75 deletions(-) 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", From 09a326a8fbdd3ef8e6e4075c6141b47a531af571 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:38:15 +0530 Subject: [PATCH 02/52] feat: add configurable execution resource policies --- docs/configuration.md | 57 ++++++++++++ src/framevitals/analysis_api.py | 133 +++++++++++++++------------- src/framevitals/config.py | 92 +++++++++++++++++-- src/framevitals/execution.py | 148 +++++++++++++++++++++++-------- src/framevitals/planning.py | 45 +++++++++- src/framevitals/planning_api.py | 75 +++++++++++----- tests/test_execution_policy.py | 152 ++++++++++++++++++++++++++++++++ 7 files changed, 572 insertions(+), 130 deletions(-) create mode 100644 docs/configuration.md create mode 100644 tests/test_execution_policy.py diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..cfc1f77 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,57 @@ +# 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. + +## Precedence + +Configuration currently resolves in this order, from lowest to highest: + +1. FrameVitals defaults +2. explicit preset +3. config mapping/TOML values +4. 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/src/framevitals/analysis_api.py b/src/framevitals/analysis_api.py index 9e0b5e7..34fb6d2 100644 --- a/src/framevitals/analysis_api.py +++ b/src/framevitals/analysis_api.py @@ -15,6 +15,7 @@ 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.result import AnalysisResult from framevitals.sources import StreamingDatasetSource, resolve_source @@ -89,78 +90,85 @@ def analyze( 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 +178,5 @@ def analyze( execution = payload.get("execution") if isinstance(execution, dict): execution["disabled_modules"] = sorted(resolved.disabled_modules) + execution["resource_policy"] = execution_policy.to_dict() return AnalysisResult(payload) diff --git a/src/framevitals/config.py b/src/framevitals/config.py index 1f4c10a..5da04b9 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -2,8 +2,8 @@ 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 @@ -34,6 +34,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, @@ -45,13 +48,22 @@ @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: @@ -62,6 +74,16 @@ def __post_init__(self) -> None: if self.workers < 1: raise ValueError("workers must be at least 1.") + for name in ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", + ): + value = getattr(self, name) + if value is not None and value < 1: + raise ValueError(f"{name} must be at least 1 when provided.") + modules = tuple(dict.fromkeys(self.disabled_modules)) unknown = sorted(set(modules) - VALID_MODULES) if unknown: @@ -72,13 +94,33 @@ 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 ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", + ): + 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 { + "max_sample_rows": self.max_sample_rows, + "max_relationship_pairs": self.max_relationship_pairs, + "max_memory_heavy_parallelism": self.max_memory_heavy_parallelism, + "max_streaming_profile_columns": self.max_streaming_profile_columns, + } + ConfigInput = AnalysisConfig | Mapping[str, Any] | str | Path | None @@ -143,10 +185,18 @@ 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"] + resource_keys = ( + "workers", + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", + ) + for key in 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( @@ -192,6 +242,20 @@ def _apply_module_overrides( values["disabled_modules"] = tuple(disabled) +def _coerce_optional_positive_int(name: str, value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"{name} must be an integer when provided.") + try: + converted = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer when provided.") from exc + if converted < 1: + raise ValueError(f"{name} must be at least 1 when provided.") + return converted + + def resolve_config( config: ConfigInput = None, *, @@ -210,6 +274,10 @@ def resolve_config( 2. explicit ``preset=`` argument 3. configuration file/mapping/object (including ``[modules]`` booleans) 4. explicit function/CLI arguments + + Resource caps currently live in ``[resources]`` and are intentionally only + configurable through the config object/file. They 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)) @@ -259,6 +327,14 @@ def resolve_config( values["disabled_modules"] = _coerce_disabled_modules( values.get("disabled_modules", ()) ) + for name in ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", + ): + 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..a5d91b2 100644 --- a/src/framevitals/execution.py +++ b/src/framevitals/execution.py @@ -11,8 +11,10 @@ from __future__ import annotations +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import asdict, dataclass -from typing import Any +from typing import Any, Iterator import numpy as np import pandas as pd @@ -39,6 +41,60 @@ } +@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", + ): + value = getattr(self, name) + if value is not None and int(value) < 1: + raise ValueError(f"{name} must be at least 1 when provided.") + + 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,6 +132,13 @@ 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, int(policy.max_sample_rows)) + + def _deterministic_stratified_positions( rows: int, target_rows: int, @@ -134,8 +197,7 @@ def derive_streaming_profile_column_limit( 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. + bounded. An active execution policy may lower the resulting width further. """ if mode not in _VALID_MODES: raise ValueError(f"Unknown analysis mode: {mode}") @@ -143,23 +205,31 @@ def derive_streaming_profile_column_limit( raise ValueError("rows and columns must be non-negative.") if columns == 0: return 0 - 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]), - ), - ) + policy = current_execution_policy() + explicit_cap = policy.max_streaming_profile_columns + + if rows == 0: + derived = int(columns) + else: + cells = int(rows) * int(columns) + cell_budget = int(_STREAMING_PROFILE_CELL_BUDGETS[mode]) + if cells <= cell_budget and columns < 10_000: + derived = int(columns) + else: + by_cells = max(1, cell_budget // max(int(rows), 1)) + derived = max( + 1, + min( + int(columns), + int(by_cells), + int(_STREAMING_PROFILE_COLUMN_CAPS[mode]), + ), + ) + + if explicit_cap is not None: + derived = min(derived, int(explicit_cap)) + return max(1, int(derived)) def derive_execution_budget( @@ -170,16 +240,16 @@ def derive_execution_budget( ) -> 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. + The thresholds are deliberately simple and deterministic for now. Per-call + :class:`ExecutionPolicy` caps are layered on top so callers can request less + work without mutating global defaults or widening any adaptive budget. """ 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.") + policy = current_execution_policy() cells = int(rows) * int(columns) large_dataset = rows >= 100_000 or cells >= 10_000_000 wide_dataset = columns >= 1_000 @@ -238,19 +308,23 @@ 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, + int(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, + int(policy.max_memory_heavy_parallelism), + ) return ExecutionBudget( mode=mode, @@ -261,13 +335,15 @@ def derive_execution_budget( 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, ) diff --git a/src/framevitals/planning.py b/src/framevitals/planning.py index 43d0e61..2818e15 100644 --- a/src/framevitals/planning.py +++ b/src/framevitals/planning.py @@ -34,6 +34,16 @@ 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 {} + def summary(self) -> dict[str, Any]: return { "dataset_name": self.get("dataset_name"), @@ -43,6 +53,7 @@ def summary(self) -> dict[str, Any]: "selected_count": len(self.selected), "skipped_count": len(self.skipped), "recommended_count": len(self.recommended), + "resource_policy": dict(self.resource_policy), } def explain_text(self) -> str: @@ -54,11 +65,39 @@ 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" + ), ] + 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', '?')}" + ) + + 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..3c8dafa 100644 --- a/src/framevitals/planning_api.py +++ b/src/framevitals/planning_api.py @@ -16,11 +16,18 @@ 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, + VALID_MODULES, + 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.planning import AnalysisPlan from framevitals.profiler import build_profile @@ -134,35 +141,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 +171,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)) @@ -267,7 +270,37 @@ 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(), "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, +) -> 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, + ) + 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/tests/test_execution_policy.py b/tests/test_execution_policy.py new file mode 100644 index 0000000..b0819d8 --- /dev/null +++ b/tests/test_execution_policy.py @@ -0,0 +1,152 @@ +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 From 18f3c028eeaa846401d91d1dc25d44ba7fc8b5f8 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:44:02 +0530 Subject: [PATCH 03/52] feat: add deterministic environment config overrides --- docs/configuration.md | 16 ++++++-- src/framevitals/config.py | 74 ++++++++++++++++++++++++++++++---- src/framevitals/planning.py | 1 - tests/test_execution_policy.py | 35 ++++++++++++++++ 4 files changed, 114 insertions(+), 12 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cfc1f77..0494fed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,14 +43,24 @@ 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. explicit preset -3. config mapping/TOML values -4. explicit Python or CLI runtime arguments +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 diff --git a/src/framevitals/config.py b/src/framevitals/config.py index 5da04b9..40ebf42 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -9,9 +9,10 @@ from __future__ import annotations from dataclasses import asdict, dataclass, replace +import os from pathlib import Path -from typing import Any, Mapping import tomllib +from typing import Any, Mapping VALID_MODES = {"quick", "standard", "deep", "research"} @@ -125,6 +126,19 @@ def execution_policy(self) -> dict[str, int | None]: 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) @@ -218,6 +232,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 {} @@ -271,17 +323,23 @@ def resolve_config( 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 - - Resource caps currently live in ``[resources]`` and are intentionally only - configurable through the config object/file. They are strict upper bounds, - not requests to increase work above the selected mode's adaptive defaults. + 2. preset defaults + 3. ``FRAMEVITALS_*`` environment overrides + 4. configuration file/mapping/object (including ``[modules]`` booleans) + 5. explicit function/CLI arguments + + Resource caps live in ``[resources]`` and matching ``FRAMEVITALS_*`` + environment variables. They 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()) elif isinstance(config, (str, Path)): diff --git a/src/framevitals/planning.py b/src/framevitals/planning.py index 2818e15..4c3da06 100644 --- a/src/framevitals/planning.py +++ b/src/framevitals/planning.py @@ -53,7 +53,6 @@ def summary(self) -> dict[str, Any]: "selected_count": len(self.selected), "skipped_count": len(self.skipped), "recommended_count": len(self.recommended), - "resource_policy": dict(self.resource_policy), } def explain_text(self) -> str: diff --git a/tests/test_execution_policy.py b/tests/test_execution_policy.py index b0819d8..d02c90b 100644 --- a/tests/test_execution_policy.py +++ b/tests/test_execution_policy.py @@ -150,3 +150,38 @@ def fake_run_full_analysis(**kwargs): 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() From ebe146b059390bbb1118e8f888e15b452e602bc5 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:31:47 +0530 Subject: [PATCH 04/52] feat: add versioned execution planner contract --- docs/planning.md | 43 ++++++ src/framevitals/analysis_api.py | 44 +----- src/framevitals/planner.py | 245 ++++++++++++++++++++++++++++++++ src/framevitals/planning.py | 36 +++++ src/framevitals/planning_api.py | 18 +-- tests/test_planner.py | 88 ++++++++++++ 6 files changed, 420 insertions(+), 54 deletions(-) create mode 100644 docs/planning.md create mode 100644 src/framevitals/planner.py create mode 100644 tests/test_planner.py 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/src/framevitals/analysis_api.py b/src/framevitals/analysis_api.py index 34fb6d2..e46eca4 100644 --- a/src/framevitals/analysis_api.py +++ b/src/framevitals/analysis_api.py @@ -17,6 +17,7 @@ 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 effective_disabled_modules from framevitals.result import AnalysisResult from framevitals.sources import StreamingDatasetSource, resolve_source @@ -24,46 +25,6 @@ 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))) - - def analyze( data: DataInput, *, @@ -85,7 +46,7 @@ def analyze( workers=workers, disabled_modules=disabled_modules, ) - effective_disabled = _effective_disabled_modules( + effective_disabled = effective_disabled_modules( resolved.mode, resolved.disabled_modules, ) @@ -179,4 +140,5 @@ def analyze( 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/planner.py b/src/framevitals/planner.py new file mode 100644 index 0000000..483346a --- /dev/null +++ b/src/framevitals/planner.py @@ -0,0 +1,245 @@ +"""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") + + +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})." + ) + + +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 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]] = {} + counts: dict[str, int] = {} + + 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." + + counts[status] = counts.get(status, 0) + 1 + decisions[module] = { + "status": status, + "reason": reason, + "resource_class": rule.resource_class, + "depends_on": list(rule.depends_on), + } + + return { + "explicit_disabled": sorted(explicit_disabled), + "effective_disabled": sorted(effective_disabled), + "decisions": decisions, + "summary": dict(sorted(counts.items())), + } + + +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 4c3da06..cf6c4f2 100644 --- a/src/framevitals/planning.py +++ b/src/framevitals/planning.py @@ -44,7 +44,23 @@ 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"), @@ -53,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: @@ -70,6 +90,8 @@ def explain_text(self) -> str: 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}" @@ -96,6 +118,20 @@ def explain_text(self) -> str: 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( diff --git a/src/framevitals/planning_api.py b/src/framevitals/planning_api.py index 3c8dafa..20961aa 100644 --- a/src/framevitals/planning_api.py +++ b/src/framevitals/planning_api.py @@ -14,14 +14,8 @@ import pandas as pd -from framevitals.analysis_selector import select_analyses from framevitals.column_roles import infer_column_roles -from framevitals.config import ( - AnalysisConfig, - 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, @@ -29,6 +23,7 @@ derive_streaming_profile_column_limit, use_execution_policy, ) +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 @@ -244,16 +239,13 @@ def _build_plan( mode=resolved.mode, ) - selection = select_analyses( + selection = 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() public_signals = { 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" From 4a3b250b332f50f63292d38857f5a1f04e2a3ac3 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:36:07 +0530 Subject: [PATCH 05/52] feat: add reusable analysis execution context --- docs/execution-context.md | 44 ++++++ src/framevitals/execution_context.py | 198 +++++++++++++++++++++++++++ src/framevitals/planning_api.py | 74 +++++++--- tests/test_execution_context.py | 126 +++++++++++++++++ 4 files changed, 426 insertions(+), 16 deletions(-) create mode 100644 docs/execution-context.md create mode 100644 src/framevitals/execution_context.py create mode 100644 tests/test_execution_context.py 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/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/planning_api.py b/src/framevitals/planning_api.py index 20961aa..5732221 100644 --- a/src/framevitals/planning_api.py +++ b/src/framevitals/planning_api.py @@ -23,6 +23,7 @@ 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 @@ -221,32 +222,72 @@ def _build_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 = build_execution_plan( - signals=dataset_signals, - analysis_mode=resolved.mode, - target_column=resolved.target, - disabled_modules=resolved.disabled_modules, - artifacts=resolved.artifacts, + 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, + ), ) selection["execution_budget"] = budget.to_dict() + context.set_fact("selection", selection) public_signals = { key: value @@ -264,6 +305,7 @@ def _build_plan( "config": resolved.to_dict(), "resource_policy": execution_policy.to_dict(), "execution_budget": budget.to_dict(), + "execution_context": context.metadata(), "signals": public_signals, "selection": selection, }) 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 From bbb3f95e8ddc8efea1f82c10b2f6af35c9865f7a Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:38:18 +0530 Subject: [PATCH 06/52] feat: make planner dependency aware --- docs/planner-scheduling.md | 31 ++++++++++ src/framevitals/planner.py | 97 +++++++++++++++++++++++++++++- tests/test_planner_dependencies.py | 70 +++++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 docs/planner-scheduling.md create mode 100644 tests/test_planner_dependencies.py 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/src/framevitals/planner.py b/src/framevitals/planner.py index 483346a..ae23eef 100644 --- a/src/framevitals/planner.py +++ b/src/framevitals/planner.py @@ -20,6 +20,7 @@ PLANNER_SCHEMA_VERSION = "1" _ALL_MODES = ("quick", "standard", "deep", "research") +_RUNNABLE_STATUSES = frozenset({"run", "conditional"}) MODE_DISABLED_MODULES: dict[str, frozenset[str]] = { @@ -112,6 +113,14 @@ def to_dict(self) -> dict[str, Any]: 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, @@ -143,6 +152,75 @@ def _missing_signal_reason( 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], @@ -162,7 +240,6 @@ def plan_execution_modules( implicit_disabled = effective_disabled - explicit_disabled decisions: dict[str, dict[str, Any]] = {} - counts: dict[str, int] = {} for module in sorted(VALID_MODULES): rule = MODULE_RULES[module] @@ -196,19 +273,35 @@ def plan_execution_modules( status = "run" reason = "Applicable under the resolved mode, signals, and configuration." - counts[status] = counts.get(status, 0) + 1 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, } 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"] From 4240cfda670db57e69829ece0bb1e0d7a73024fb Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:40:06 +0530 Subject: [PATCH 07/52] docs: align 0.3 roadmap and execution docs --- CHANGELOG.md | 18 +++++++++++++++++- mkdocs.yml | 4 ++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fc975..1f3df72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,23 @@ 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. +### 0.3.0 development + +#### Configuration and execution policy + +- Added enforceable resource caps for sampled rows, relationship-pair work, memory-heavy parallelism, and ultra-wide streaming profile width. User caps only tighten the adaptive mode budget and never silently increase work. +- Added deterministic `FRAMEVITALS_*` environment configuration with precedence between preset defaults and project/runtime configuration. +- Added the `exhaustive` preset as the forward-looking name for the deepest built-in policy while retaining `research` compatibility throughout the 0.x series. +- Added per-run `ExecutionPolicy` scoping so concurrent analyses can use different limits without mutating process-global state. + +#### Planning and reusable execution state + +- Added a versioned execution-planner contract with structured per-module status, reason, resource class, dependency, explicit-disable, and effective-disable metadata. +- Centralized built-in mode-to-module policy in the planner and made `analyze()` consume the same policy so planning and runtime cannot drift on mode disables. +- Added dependency propagation so disabling or invalidating an upstream module blocks dependent work instead of falsely advertising it as runnable. +- Added topologically ordered execution stages and a flattened runnable-module order as the scheduling contract for progressively planner-controlled execution. +- Added a per-run `AnalysisContext` with a thread-safe exactly-once intermediate cache, authoritative fact registry, named reusable bounded samples, deterministic seed, and metadata-only provenance. +- Updated `framevitals.plan()` to reuse one context for column roles, dataset signals, execution-budget derivation, and planner construction, and to expose context/cache/sample provenance without serializing raw sample values. ## 0.2.0 - 2026-08-17 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 From a7e4a1c38f29b6114a2cd8b2a7d4dcc0eb450a1b Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:43:35 +0530 Subject: [PATCH 08/52] feat: expose explicit resource caps in Python APIs --- src/framevitals/__init__.py | 16 +++++++ src/framevitals/analysis_api.py | 8 ++++ src/framevitals/api.py | 16 +++++++ src/framevitals/config.py | 59 +++++++++-------------- src/framevitals/planning_api.py | 8 ++++ tests/test_resource_overrides.py | 80 ++++++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 36 deletions(-) create mode 100644 tests/test_resource_overrides.py diff --git a/src/framevitals/__init__.py b/src/framevitals/__init__.py index 4039a1a..3360fee 100644 --- a/src/framevitals/__init__.py +++ b/src/framevitals/__init__.py @@ -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/analysis_api.py b/src/framevitals/analysis_api.py index e46eca4..1f411d8 100644 --- a/src/framevitals/analysis_api.py +++ b/src/framevitals/analysis_api.py @@ -35,6 +35,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( @@ -45,6 +49,10 @@ 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( resolved.mode, 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 40ebf42..b5fc3e3 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -46,6 +46,13 @@ }, } +_RESOURCE_KEYS = ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", +) + @dataclass(frozen=True, slots=True) class AnalysisConfig: @@ -75,12 +82,7 @@ def __post_init__(self) -> None: if self.workers < 1: raise ValueError("workers must be at least 1.") - for name in ( - "max_sample_rows", - "max_relationship_pairs", - "max_memory_heavy_parallelism", - "max_streaming_profile_columns", - ): + for name in _RESOURCE_KEYS: value = getattr(self, name) if value is not None and value < 1: raise ValueError(f"{name} must be at least 1 when provided.") @@ -98,12 +100,7 @@ def to_dict(self) -> dict[str, Any]: 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 ( - "max_sample_rows", - "max_relationship_pairs", - "max_memory_heavy_parallelism", - "max_streaming_profile_columns", - ): + for name in _RESOURCE_KEYS: if payload.get(name) is None: payload.pop(name, None) return payload @@ -115,12 +112,7 @@ def module_enabled(self, name: str) -> bool: def execution_policy(self) -> dict[str, int | None]: """Return the resource caps understood by the adaptive execution layer.""" - return { - "max_sample_rows": self.max_sample_rows, - "max_relationship_pairs": self.max_relationship_pairs, - "max_memory_heavy_parallelism": self.max_memory_heavy_parallelism, - "max_streaming_profile_columns": self.max_streaming_profile_columns, - } + return {name: getattr(self, name) for name in _RESOURCE_KEYS} ConfigInput = AnalysisConfig | Mapping[str, Any] | str | Path | None @@ -199,14 +191,7 @@ def _extract_values( elif key in mapping: values[key] = mapping[key] - resource_keys = ( - "workers", - "max_sample_rows", - "max_relationship_pairs", - "max_memory_heavy_parallelism", - "max_streaming_profile_columns", - ) - for key in resource_keys: + for key in ("workers", *_RESOURCE_KEYS): if key in resources: values[key] = resources[key] elif key in mapping: @@ -317,8 +302,12 @@ 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: @@ -328,9 +317,8 @@ def resolve_config( 4. configuration file/mapping/object (including ``[modules]`` booleans) 5. explicit function/CLI arguments - Resource caps live in ``[resources]`` and matching ``FRAMEVITALS_*`` - environment variables. They are strict upper bounds, not requests to - increase work above the selected mode's adaptive defaults. + 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)) @@ -369,6 +357,10 @@ 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}) @@ -385,12 +377,7 @@ def resolve_config( values["disabled_modules"] = _coerce_disabled_modules( values.get("disabled_modules", ()) ) - for name in ( - "max_sample_rows", - "max_relationship_pairs", - "max_memory_heavy_parallelism", - "max_streaming_profile_columns", - ): + for name in _RESOURCE_KEYS: values[name] = _coerce_optional_positive_int(name, values.get(name)) return AnalysisConfig(**values) diff --git a/src/framevitals/planning_api.py b/src/framevitals/planning_api.py index 5732221..218ace0 100644 --- a/src/framevitals/planning_api.py +++ b/src/framevitals/planning_api.py @@ -320,6 +320,10 @@ def plan( 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( @@ -330,6 +334,10 @@ def plan( 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): 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 From 89f9d3ba82de844c0f50ad3ab7285be185af8af9 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:46:37 +0530 Subject: [PATCH 09/52] fix: enforce sample caps in quality diagnostics --- src/framevitals/pipeline.py | 2 +- tests/test_resource_hard_cap.py | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/test_resource_hard_cap.py 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/tests/test_resource_hard_cap.py b/tests/test_resource_hard_cap.py new file mode 100644 index 0000000..8f901dd --- /dev/null +++ b/tests/test_resource_hard_cap.py @@ -0,0 +1,37 @@ +import pandas as pd + +import framevitals + + +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 From 50950d43d7a545a5d6f061e478e738cf34e50788 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:54:58 +0530 Subject: [PATCH 10/52] fix: preserve analysis mode compatibility aliases --- src/framevitals/analysis_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/framevitals/analysis_api.py b/src/framevitals/analysis_api.py index 1f411d8..81aed43 100644 --- a/src/framevitals/analysis_api.py +++ b/src/framevitals/analysis_api.py @@ -17,13 +17,19 @@ 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 effective_disabled_modules +from framevitals.planner import MODE_DISABLED_MODULES, effective_disabled_modules from framevitals.result import AnalysisResult from framevitals.sources import StreamingDatasetSource, resolve_source DataInput = Any +# 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( data: DataInput, From 129f0b594e8e96daff0b9f3967bd29e17e3b26d7 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:57:52 +0530 Subject: [PATCH 11/52] fix: remove report generator import side effect --- src/framevitals/report_generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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, From 1f84d1d89befe848737f713b8433dd17f2e8d679 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:57:56 +0530 Subject: [PATCH 12/52] chore: remove unused report tab placeholder --- frontend/src/pages/report/_placeholder.tsx | 38 ---------------------- 1 file changed, 38 deletions(-) delete mode 100644 frontend/src/pages/report/_placeholder.tsx 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 ; - }; -} From f6c4ba8bad616a197dd2b7b1c3b99c3dbc2c157e Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:00:07 +0530 Subject: [PATCH 13/52] fix: enforce hard sample caps in streaming quality --- src/framevitals/streaming_quality.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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, From 297618dbd2d1f0e6129054dfbc206c4f33486af8 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:00:20 +0530 Subject: [PATCH 14/52] test: cover streaming resource hard caps --- tests/test_streaming_resource_policy.py | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_streaming_resource_policy.py 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 From 08c3c48220eafd1742ad69f9cc90d53919c5a9ec Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:01:07 +0530 Subject: [PATCH 15/52] fix: preserve config object precedence over environment --- src/framevitals/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/framevitals/config.py b/src/framevitals/config.py index b5fc3e3..c570439 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -329,7 +329,10 @@ def resolve_config( 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) From f0dbef53231feb87d0dc831a94b4cbf489dc3306 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:01:16 +0530 Subject: [PATCH 16/52] test: cover resolved config precedence --- tests/test_config_object_precedence.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/test_config_object_precedence.py 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 From 5d3fea348479f1d3a1bb48b7caf795a5777db60a Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:02:22 +0530 Subject: [PATCH 17/52] fix: harden runtime configuration validation --- src/framevitals/config.py | 88 +++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/src/framevitals/config.py b/src/framevitals/config.py index c570439..b6a1fa5 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -9,6 +9,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, replace +from numbers import Integral import os from pathlib import Path import tomllib @@ -54,6 +55,40 @@ ) +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. @@ -74,20 +109,29 @@ class AnalysisConfig: 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: - value = getattr(self, name) - if value is not None and value < 1: - raise ValueError(f"{name} must be at least 1 when provided.") + 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( @@ -159,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]]: @@ -279,20 +315,6 @@ def _apply_module_overrides( values["disabled_modules"] = tuple(disabled) -def _coerce_optional_positive_int(name: str, value: Any) -> int | None: - if value is None: - return None - if isinstance(value, bool): - raise ValueError(f"{name} must be an integer when provided.") - try: - converted = int(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must be an integer when provided.") from exc - if converted < 1: - raise ValueError(f"{name} must be at least 1 when provided.") - return converted - - def resolve_config( config: ConfigInput = None, *, @@ -367,11 +389,7 @@ def resolve_config( } 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): From f10daff90010b9da0674eed4a5465cb5b89a09ca Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:02:33 +0530 Subject: [PATCH 18/52] test: harden direct config validation --- tests/test_config_validation.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_config_validation.py 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}}) From 779b7502b53419c167e3ebd4dedc6e7e54e0a97f Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:04:29 +0530 Subject: [PATCH 19/52] refactor: harden RAG runtime configuration --- src/framevitals/rag_index.py | 258 ++++++++++++++++++----------------- 1 file changed, 133 insertions(+), 125 deletions(-) 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) From e73114a012364b30587fd6bc4dd1cebb1b73be56 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:04:39 +0530 Subject: [PATCH 20/52] test: cover RAG runtime compatibility settings --- tests/test_rag_runtime_config.py | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_rag_runtime_config.py 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) From 2bbd0e76097b6eb98227c77eb8c0262e701decd1 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:07:47 +0530 Subject: [PATCH 21/52] refactor: harden and unify Flask execution --- app.py | 508 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 284 insertions(+), 224 deletions(-) diff --git a/app.py b/app.py index f202231..294fed4 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 @@ -20,78 +25,124 @@ from framevitals.frontend_api import build_dashboard_payload from framevitals.loader import 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}$") +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) -# --------------------------------------------------------------------------- -# 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) +_WEB_CACHE_LIMIT = _bounded_positive_env("FRAMEVITALS_WEB_CACHE_LIMIT", 16, 128) +_REPORT_JOB_LIMIT = max(_WEB_CACHE_LIMIT, 32) + + +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() +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 _report_path(dataset_id: str) -> Path: - return REPORT_DIR / f"{dataset_id}_report.pdf" + return REPORT_DIR / f"{_validate_dataset_id(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) + + +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,6 +151,7 @@ def _set_report_job( pdf_path: Path | None = None, error: str | None = None, ) -> dict: + dataset_id = _validate_dataset_id(dataset_id) job = { "status": status, "pdf_path": str(pdf_path) if pdf_path else None, @@ -107,38 +159,65 @@ def _set_report_job( } 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 = _report_path(dataset_id) + if report_path.exists() and report_path.stat().st_size > 0: + 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() + except Exception as exc: # optional report generation must fail soft + app.logger.exception("PDF generation failed for dataset %s", dataset_id) _set_report_job(dataset_id, "failed", error=str(exc)) Thread(target=worker, daemon=True).start() @@ -146,18 +225,11 @@ def worker(): 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, - } - - if not job and report_path.exists() and report_path.stat().st_size > 0: + if report_path.exists() and report_path.stat().st_size > 0: return { "status": "ready", "ready": True, @@ -168,12 +240,36 @@ def _report_status_payload(dataset_id: str) -> dict: status = job.get("status") or "pending" return { "status": status, - "ready": status == "ready", + "ready": False, "pdf_path": job.get("pdf_path"), "error": job.get("error"), } +def _store_session( + *, + dataset_id: str, + file_path: Path, + original_filename: str, + analysis_mode: str, + target_column: str | None, +) -> None: + session["dataset_id"] = _validate_dataset_id(dataset_id) + session["file_path"] = str(file_path) + 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 + try: + path.unlink(missing_ok=True) + except OSError: + app.logger.warning("Could not remove temporary upload %s", path) + + @app.route("/") def index(): return render_template("index.html") @@ -184,7 +280,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 +295,30 @@ 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( + 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) - + _store_session( + dataset_id=dataset_id, + file_path=file_path, + original_filename=original_filename, + analysis_mode=analysis_mode, + target_column=target_column, + ) + return render_template("report.html", result=DotDict.from_dict(result)) except Exception as exc: - import traceback - - traceback.print_exc() - return render_template("error.html", message=str(exc)) + app.logger.exception("Server-rendered analysis failed") + return render_template("error.html", message=str(exc)), 500 @app.route("/api/analyze", methods=["POST"]) @@ -239,7 +327,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 +337,19 @@ 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( + 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,27 +359,22 @@ 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, + file_path=file_path, + 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() + app.logger.exception("API analysis failed") return jsonify({"error": str(exc)}), 500 @@ -305,38 +382,39 @@ def api_analyze(): def ask(): try: dataset_id = session.get("dataset_id") - file_path = session.get("file_path") + file_path_value = 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 or not file_path_value: return redirect(url_for("index")) + dataset_id = _validate_dataset_id(dataset_id) + file_path = Path(file_path_value) + + # 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) - 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) - - _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,28 +434,31 @@ def ask(): result["chat_answer"] = answer result["chat_question"] = question - - result_dot = DotDict.from_dict(result) - return render_template("report.html", result=result_dot) - + return render_template("report.html", result=DotDict.from_dict(result)) except Exception as exc: - return render_template("error.html", message=str(exc)) + app.logger.exception("Server-rendered question answering failed") + return render_template("error.html", message=str(exc)), 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 as exc: + return jsonify({"error": str(exc)}), 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.", @@ -402,7 +483,7 @@ def api_ask(): fast=(mode != "full"), ) except Exception as exc: - response = answer_dataset_question( + fallback = answer_dataset_question( question=question, profile=cached_result["profile"], health=cached_result["health"], @@ -411,8 +492,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", str(exc)), "trace": {}, } @@ -423,34 +504,23 @@ def api_ask(): "answer": response.get("answer"), "trace": response.get("trace", {}), }) - except Exception as exc: - import traceback - - traceback.print_exc() + app.logger.exception("API question answering failed") return jsonify({"error": str(exc)}), 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 as exc: + return jsonify({"error": str(exc)}), 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." @@ -471,61 +541,45 @@ def api_ai_report(): 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 - + cached["ai_report"] = ai_report + _cache_analysis(dataset_id, cached) return safe_jsonify(ai_report) - except Exception as exc: - import traceback - - traceback.print_exc() + app.logger.exception("AI report generation failed") return jsonify({"error": str(exc)}), 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), + "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": "v3", }) @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 +592,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 +604,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() + app.logger.exception("Dataset comparison failed") return jsonify({"error": str(exc)}), 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 +639,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 +652,31 @@ 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() + app.logger.exception("Self-comparison failed") return jsonify({"error": str(exc)}), 500 + finally: + _unlink_quietly(ds_path) @app.route("/download-cleaned/") def download_cleaned(dataset_id): + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return render_template("error.html", message="Invalid dataset identifier."), 400 path = Path("cleaned") / f"{dataset_id}_cleaned.csv" - if path.exists(): + if path.exists() and path.is_file(): 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 as exc: + return jsonify({"error": str(exc)}), 400 status = _report_status_payload(dataset_id) status["downloadUrl"] = f"/download-report/{dataset_id}" status["dataset_id"] = dataset_id @@ -633,24 +686,24 @@ def api_report_status(dataset_id): @app.route("/download-report/") def download_report(dataset_id): try: + dataset_id = _validate_dataset_id(dataset_id) pdf_path = _report_path(dataset_id) report_status = _report_status_payload(dataset_id) if report_status["ready"] and pdf_path.exists() and pdf_path.stat().st_size > 0: return send_file(pdf_path, as_attachment=True) - 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: 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')}" @@ -659,12 +712,19 @@ def download_report(dataset_id): "No cached analysis was found for this dataset. " "Please run analysis again first." ) - return render_template("error.html", message=message), 202 - + except ValueError: + return render_template("error.html", message="Invalid dataset identifier."), 400 except Exception as exc: - return render_template("error.html", message=str(exc)) + app.logger.exception("PDF download failed") + return render_template("error.html", message=str(exc)), 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) From cbaff60b2fec698c4d61747e6badac0fc8d8ec76 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:08:02 +0530 Subject: [PATCH 22/52] test: cover Flask execution hardening --- tests/test_web_app_hardening.py | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/test_web_app_hardening.py 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() From a60e9b7f1cddc9bd1da64d18b655ca120db8f758 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:10:30 +0530 Subject: [PATCH 23/52] fix: make safe pandas evaluation side-effect free --- src/framevitals/safe_pandas.py | 341 ++++++++++++++++++++------------- 1 file changed, 207 insertions(+), 134 deletions(-) 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, + } From 8a94f2394ce9fbda6a72b2d470ef05e1c34be353 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:10:38 +0530 Subject: [PATCH 24/52] test: cover read-only pandas sandbox --- tests/test_safe_pandas_hardening.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_safe_pandas_hardening.py 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"] From 7aac9336a0140fbb9a09e1c59999d85a2a4512d0 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:11:04 +0530 Subject: [PATCH 25/52] fix: preserve safe upload extensions --- src/framevitals/security.py | 44 +++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/framevitals/security.py b/src/framevitals/security.py index 8da94c9..f42e928 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("._") + 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 From 335331a4e1d51f6d41238c3ee31432a1823083ad Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:11:13 +0530 Subject: [PATCH 26/52] test: cover upload and CSV safety edge cases --- tests/test_security_hardening.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/test_security_hardening.py 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" From dc18da60f843ae299a54d28a0c254d6b7325fcee Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:13:23 +0530 Subject: [PATCH 27/52] fix: preserve filename sanitizer compatibility --- src/framevitals/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framevitals/security.py b/src/framevitals/security.py index f42e928..fc06352 100644 --- a/src/framevitals/security.py +++ b/src/framevitals/security.py @@ -40,7 +40,7 @@ def validate_file(filename: str) -> None: 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" From 906197748838775453819e60ae2617366f0f07c8 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:13:51 +0530 Subject: [PATCH 28/52] fix: proxy dataset download routes correctly --- frontend/vercel.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" } ] } From 7503451659a2b13c2ce862f2c4b0b0b9678d4005 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:14:14 +0530 Subject: [PATCH 29/52] fix: harden frontend API response handling --- frontend/src/lib/api.ts | 109 +++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 52 deletions(-) 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, }, From 8bc9ea85678d18e20dc6bca4ff867eb9989f09b4 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:15:01 +0530 Subject: [PATCH 30/52] fix: validate execution controls at runtime boundary --- src/framevitals/execution.py | 104 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/src/framevitals/execution.py b/src/framevitals/execution.py index a5d91b2..0f5f25e 100644 --- a/src/framevitals/execution.py +++ b/src/framevitals/execution.py @@ -14,6 +14,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass +from numbers import Integral from typing import Any, Iterator import numpy as np @@ -23,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, @@ -41,6 +38,28 @@ } +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. @@ -63,9 +82,11 @@ def __post_init__(self) -> None: "max_memory_heavy_parallelism", "max_streaming_profile_columns", ): - value = getattr(self, name) - if value is not None and int(value) < 1: - raise ValueError(f"{name} must be at least 1 when provided.") + object.__setattr__( + self, + name, + _optional_positive_int(name, getattr(self, name)), + ) def to_dict(self) -> dict[str, int | None]: return asdict(self) @@ -136,7 +157,7 @@ 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, int(policy.max_sample_rows)) + return min(bounded, policy.max_sample_rows) def _deterministic_stratified_positions( @@ -145,15 +166,9 @@ def _deterministic_stratified_positions( *, 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) @@ -170,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) @@ -179,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 @@ -193,16 +204,11 @@ 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. An active execution policy may lower the resulting width further. - """ + """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 @@ -210,25 +216,25 @@ def derive_streaming_profile_column_limit( explicit_cap = policy.max_streaming_profile_columns if rows == 0: - derived = int(columns) + derived = columns else: - cells = int(rows) * int(columns) + cells = rows * columns cell_budget = int(_STREAMING_PROFILE_CELL_BUDGETS[mode]) if cells <= cell_budget and columns < 10_000: - derived = int(columns) + derived = columns else: - by_cells = max(1, cell_budget // max(int(rows), 1)) + by_cells = max(1, cell_budget // max(rows, 1)) derived = max( 1, min( - int(columns), + columns, int(by_cells), int(_STREAMING_PROFILE_COLUMN_CAPS[mode]), ), ) if explicit_cap is not None: - derived = min(derived, int(explicit_cap)) + derived = min(derived, explicit_cap) return max(1, int(derived)) @@ -238,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. Per-call - :class:`ExecutionPolicy` caps are layered on top so callers can request less - work without mutating global defaults or widening any adaptive budget. - """ + """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) policy = current_execution_policy() - cells = int(rows) * int(columns) + cells = rows * columns large_dataset = rows >= 100_000 or cells >= 10_000_000 wide_dataset = columns >= 1_000 ultra_wide_dataset = columns >= 10_000 @@ -316,20 +317,20 @@ def derive_execution_budget( if policy.max_relationship_pairs is not None: relationship_budget = min( relationship_budget, - int(policy.max_relationship_pairs), + policy.max_relationship_pairs, ) 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, - int(policy.max_memory_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, @@ -356,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, { @@ -371,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, { From b4fa4586669967c46f12d1d067f7e6ab66d0b85e Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:15:12 +0530 Subject: [PATCH 31/52] test: cover execution boundary validation --- tests/test_execution_validation.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_execution_validation.py 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) From 345d0032c3daef23ed613f51ec6a37afedf12977 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:17:53 +0530 Subject: [PATCH 32/52] fix: preserve AI fallback diagnostics --- src/framevitals/ai_insights.py | 320 ++++++++++++++++++++++----------- 1 file changed, 215 insertions(+), 105 deletions(-) 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}", + ) From 52d2b1e368982d4286790dcb4f8efda9db5eaba1 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:18:05 +0530 Subject: [PATCH 33/52] test: preserve AI fallback diagnostics --- tests/test_ai_insights_hardening.py | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_ai_insights_hardening.py 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" From 6414ba5b04f3d89dcb68bc7da05405508189aa8b Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:19:05 +0530 Subject: [PATCH 34/52] fix: validate snapshot integrity --- src/framevitals/snapshots.py | 59 ++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 23 deletions(-) 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: From 12e07381aebdcf466c6e5e3d4ef85de82a97bdfb Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:19:17 +0530 Subject: [PATCH 35/52] test: cover snapshot integrity validation --- tests/test_snapshot_integrity.py | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_snapshot_integrity.py 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) From c486606da4b62d9a562a41e6f47f2787a6e49fbf Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:49:11 +0530 Subject: [PATCH 36/52] fix: honor sub-ten quality sample caps --- src/framevitals/quality_diagnostics.py | 32 ++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) 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), }, From 18e4a611acb84d90a3f1482fc2010519a5936b1d Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:49:31 +0530 Subject: [PATCH 37/52] test: execute quality diagnostics below ten-row cap --- tests/test_resource_hard_cap.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_resource_hard_cap.py b/tests/test_resource_hard_cap.py index 8f901dd..6b687a0 100644 --- a/tests/test_resource_hard_cap.py +++ b/tests/test_resource_hard_cap.py @@ -1,6 +1,7 @@ import pandas as pd import framevitals +from framevitals.quality_diagnostics import run_quality_diagnostics def test_materialized_quality_diagnostics_respect_subten_hard_cap(monkeypatch): @@ -35,3 +36,16 @@ def fake_quality(frame, **kwargs): 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 From df538e5795dd30a98204240c94aab5f515a6eb40 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:49:55 +0530 Subject: [PATCH 38/52] test: compare valid changed snapshots --- tests/test_cli_monitoring.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) 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", From 71cffce103b129274189c9c56ba6269156e9934b Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:04:58 +0530 Subject: [PATCH 39/52] fix: keep upload paths server-controlled --- src/framevitals/loader.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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] From 44b99f747ec1ab63a156b9aad97148eae0bcd58b Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:06:24 +0530 Subject: [PATCH 40/52] fix: close web security findings before release --- app.py | 194 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 137 insertions(+), 57 deletions(-) diff --git a/app.py b/app.py index 294fed4..17731b4 100644 --- a/app.py +++ b/app.py @@ -20,16 +20,18 @@ 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") @@ -77,6 +79,7 @@ def safe_jsonify(payload): app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 ANALYSIS_CACHE: OrderedDict[str, dict] = OrderedDict() +UPLOAD_PATHS: OrderedDict[str, str] = OrderedDict() REPORT_JOBS: OrderedDict[str, dict] = OrderedDict() REPORT_LOCK = Lock() @@ -108,8 +111,51 @@ def _validate_dataset_id(dataset_id: str | None) -> str: return dataset_id -def _report_path(dataset_id: str) -> Path: - return REPORT_DIR / f"{_validate_dataset_id(dataset_id)}_report.pdf" +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 _cache_analysis(dataset_id: str, result: dict) -> None: @@ -121,6 +167,7 @@ def _cache_analysis(dataset_id: str, result: dict) -> None: 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: @@ -152,9 +199,12 @@ def _set_report_job( 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: @@ -203,8 +253,8 @@ def _queue_pdf_generation(dataset_id: str, result: dict | None = None) -> dict: if current_job.get("status") in {"queued", "running"}: return current_job if current_job.get("status") == "ready": - report_path = _report_path(dataset_id) - if report_path.exists() and report_path.stat().st_size > 0: + 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") @@ -216,9 +266,13 @@ def worker() -> None: pdf_path = generate_pdf_report(cached_result) _set_report_job(dataset_id, "ready", pdf_path=pdf_path) - except Exception as exc: # optional report generation must fail soft + 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=str(exc)) + _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) @@ -227,9 +281,9 @@ def worker() -> None: 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) + report_path = _trusted_generated_path(job.get("pdf_path"), REPORT_DIR) - if 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, @@ -238,24 +292,26 @@ def _report_status_payload(dataset_id: str) -> dict: } status = job.get("status") or "pending" + if status == "ready": + status = "missing" return { "status": status, "ready": False, - "pdf_path": job.get("pdf_path"), - "error": job.get("error"), + "pdf_path": None, + "error": job.get("error") if status == "failed" else None, } def _store_session( *, dataset_id: str, - file_path: Path, 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["file_path"] = str(file_path) session["original_filename"] = original_filename session["analysis_mode"] = _normalize_analysis_mode(analysis_mode) session["target_column"] = target_column @@ -264,10 +320,14 @@ def _store_session( 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: - path.unlink(missing_ok=True) + trusted.unlink(missing_ok=True) except OSError: - app.logger.warning("Could not remove temporary upload %s", path) + app.logger.warning("Could not remove temporary upload %s", trusted) @app.route("/") @@ -298,6 +358,7 @@ def analyze(): ), 400 dataset_id, file_path, original_filename = save_uploaded_file(uploaded_file) + _cache_upload_path(dataset_id, file_path) result = _run_web_analysis( dataset_id=dataset_id, file_path=file_path, @@ -310,15 +371,17 @@ def analyze(): result["report_status"] = _report_status_payload(dataset_id) _store_session( dataset_id=dataset_id, - file_path=file_path, original_filename=original_filename, analysis_mode=analysis_mode, target_column=target_column, ) return render_template("report.html", result=DotDict.from_dict(result)) - except Exception as exc: + except Exception: app.logger.exception("Server-rendered analysis failed") - return render_template("error.html", message=str(exc)), 500 + return render_template( + "error.html", + message="Dataset analysis failed. Check the server logs for details.", + ), 500 @app.route("/api/analyze", methods=["POST"]) @@ -341,6 +404,7 @@ def api_analyze(): return jsonify({"error": "Please upload a valid dataset file."}), 400 dataset_id, file_path, original_filename = save_uploaded_file(uploaded_file) + _cache_upload_path(dataset_id, file_path) df = load_dataset(file_path) result = _run_web_analysis( dataset_id=dataset_id, @@ -367,31 +431,31 @@ def api_analyze(): payload["downloadLinks"]["reportStatus"] = report_status["status"] _store_session( dataset_id=dataset_id, - file_path=file_path, original_filename=original_filename, analysis_mode=analysis_mode, target_column=target_column, ) return safe_jsonify(payload) - except Exception as exc: + except Exception: app.logger.exception("API analysis failed") - return jsonify({"error": str(exc)}), 500 + return jsonify({"error": "Dataset analysis failed."}), 500 @app.route("/ask", methods=["POST"]) def ask(): try: dataset_id = session.get("dataset_id") - file_path_value = session.get("file_path") original_filename = session.get("original_filename", "dataset") 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_value: + if not dataset_id: return redirect(url_for("index")) dataset_id = _validate_dataset_id(dataset_id) - file_path = Path(file_path_value) + file_path = _get_upload_path(dataset_id) + if file_path is None: + return redirect(url_for("index")) # Reuse the analysis produced by the upload route. The old handler # reran the entire pipeline for every question, which was needlessly @@ -435,9 +499,12 @@ def ask(): result["chat_answer"] = answer result["chat_question"] = question return render_template("report.html", result=DotDict.from_dict(result)) - except Exception as exc: + except Exception: app.logger.exception("Server-rendered question answering failed") - return render_template("error.html", message=str(exc)), 500 + return render_template( + "error.html", + message="Question answering failed. Check the server logs for details.", + ), 500 @app.route("/api/ask", methods=["POST"]) @@ -453,8 +520,8 @@ def api_ask(): return jsonify({"error": "Missing 'question' in request body."}), 400 try: dataset_id = _validate_dataset_id(dataset_id) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + 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 @@ -464,12 +531,13 @@ def api_ask(): "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: @@ -482,7 +550,7 @@ def api_ask(): analysis_result=cached_result, fast=(mode != "full"), ) - except Exception as exc: + except Exception: fallback = answer_dataset_question( question=question, profile=cached_result["profile"], @@ -493,7 +561,7 @@ def api_ask(): ) response = { "source": fallback.get("source", "fallback"), - "answer": fallback.get("answer", str(exc)), + "answer": fallback.get("answer") or "Question answering is unavailable.", "trace": {}, } @@ -504,9 +572,9 @@ def api_ask(): "answer": response.get("answer"), "trace": response.get("trace", {}), }) - except Exception as exc: + except Exception: app.logger.exception("API question answering failed") - return jsonify({"error": str(exc)}), 500 + return jsonify({"error": "Question answering failed."}), 500 @app.route("/api/ai-report", methods=["POST"]) @@ -517,8 +585,8 @@ def api_ai_report(): dataset_id = body.get("dataset_id") or session.get("dataset_id") try: dataset_id = _validate_dataset_id(dataset_id) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + except ValueError: + return jsonify({"error": "Invalid dataset identifier."}), 400 cached = _get_cached_analysis(dataset_id) if cached is None: @@ -538,15 +606,19 @@ 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)} + 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: + except Exception: app.logger.exception("AI report generation failed") - return jsonify({"error": str(exc)}), 500 + return jsonify({"error": "AI report generation failed."}), 500 @app.route("/api/health") @@ -572,7 +644,7 @@ def _probe(host: str, port: int, timeout: float = 0.4) -> bool: ), "cached_analyses": cached_count, "pdf_jobs": job_count, - "version": "v3", + "version": FRAMEVITALS_VERSION, }) @@ -607,9 +679,9 @@ def api_compare(): report["reference_filename"] = ref_name report["current_filename"] = cur_name return safe_jsonify(report) - except Exception as exc: + except Exception: app.logger.exception("Dataset comparison failed") - return jsonify({"error": str(exc)}), 500 + return jsonify({"error": "Dataset comparison failed."}), 500 finally: _unlink_quietly(ref_path) _unlink_quietly(cur_path) @@ -652,9 +724,9 @@ def api_compare_self(): report["split_by"] = date_column report["split_ratio"] = ratio return safe_jsonify(report) - except Exception as exc: + except Exception: app.logger.exception("Self-comparison failed") - return jsonify({"error": str(exc)}), 500 + return jsonify({"error": "Dataset self-comparison failed."}), 500 finally: _unlink_quietly(ds_path) @@ -665,8 +737,12 @@ def download_cleaned(dataset_id): dataset_id = _validate_dataset_id(dataset_id) except ValueError: return render_template("error.html", message="Invalid dataset identifier."), 400 - path = Path("cleaned") / f"{dataset_id}_cleaned.csv" - if path.exists() and path.is_file(): + + 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."), 404 @@ -675,8 +751,8 @@ def download_cleaned(dataset_id): def api_report_status(dataset_id): try: dataset_id = _validate_dataset_id(dataset_id) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + 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 @@ -687,10 +763,10 @@ def api_report_status(dataset_id): def download_report(dataset_id): try: dataset_id = _validate_dataset_id(dataset_id) - pdf_path = _report_path(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 = _get_cached_analysis(dataset_id) @@ -698,7 +774,8 @@ def download_report(dataset_id): _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 = ( @@ -706,7 +783,7 @@ def download_report(dataset_id): "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. " @@ -715,9 +792,12 @@ def download_report(dataset_id): return render_template("error.html", message=message), 202 except ValueError: return render_template("error.html", message="Invalid dataset identifier."), 400 - except Exception as exc: + except Exception: app.logger.exception("PDF download failed") - return render_template("error.html", message=str(exc)), 500 + return render_template( + "error.html", + message="PDF download failed. Check the server logs for details.", + ), 500 if __name__ == "__main__": From 84669a4d977c8a4a4a556e94bd9128a27913d43b Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:20:11 +0530 Subject: [PATCH 41/52] fix: avoid logging managed upload paths --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 17731b4..1fd34e2 100644 --- a/app.py +++ b/app.py @@ -327,7 +327,7 @@ def _unlink_quietly(path: Path | None) -> None: try: trusted.unlink(missing_ok=True) except OSError: - app.logger.warning("Could not remove temporary upload %s", trusted) + app.logger.warning("Could not remove temporary upload") @app.route("/") From f1a27c2b657e8098e809aedc6361b85980ae3397 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:23:57 +0530 Subject: [PATCH 42/52] chore: add one-shot 0.3.0 lockfile sync --- .github/workflows/release-lock-sync.yml | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/release-lock-sync.yml diff --git a/.github/workflows/release-lock-sync.yml b/.github/workflows/release-lock-sync.yml new file mode 100644 index 0000000..17a1e2a --- /dev/null +++ b/.github/workflows/release-lock-sync.yml @@ -0,0 +1,35 @@ +name: Sync 0.3.0 Cargo lockfile + +on: + push: + branches: ["release/0.3.0"] + paths: + - "rust/framevitals-py/Cargo.toml" + +permissions: + contents: write + +jobs: + sync-lockfile: + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v6 + with: + ref: release/0.3.0 + + - name: Refresh workspace lockfile from release manifests + run: cargo check --manifest-path rust/Cargo.toml --workspace + + - name: Commit lockfile if changed + shell: bash + run: | + if git diff --quiet -- rust/Cargo.lock; then + echo "Cargo.lock already synchronized." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add rust/Cargo.lock + git commit -m "chore: sync Cargo.lock for 0.3.0" + git push origin HEAD:release/0.3.0 From 700f39d7996136020d572debbe4eccbec24b2a55 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:24:06 +0530 Subject: [PATCH 43/52] release: bump Rust core to 0.3.0 --- rust/framevitals-core/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 29ac51ef3dfc50c36af61ada815e807b78c10d41 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:24:23 +0530 Subject: [PATCH 44/52] release: bump Python package to 0.3.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 990d49d2386c292d8231e70f423151690e7f7950 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:25:21 +0530 Subject: [PATCH 45/52] release: expose FrameVitals 0.3.0 version --- src/framevitals/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framevitals/__init__.py b/src/framevitals/__init__.py index 3360fee..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): From 4f10c9d04db881bb426a9f4c4f21b653e63300aa Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:26:14 +0530 Subject: [PATCH 46/52] release: finalize 0.3.0 changelog --- CHANGELOG.md | 66 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3df72..dc851ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,63 @@ FrameVitals follows semantic versioning while the public API matures. The 0.x se ## Unreleased -### 0.3.0 development +No user-facing changes are currently queued beyond 0.3.0. -#### Configuration and execution policy +## 0.3.0 - 2026-09-05 -- Added enforceable resource caps for sampled rows, relationship-pair work, memory-heavy parallelism, and ultra-wide streaming profile width. User caps only tighten the adaptive mode budget and never silently increase work. -- Added deterministic `FRAMEVITALS_*` environment configuration with precedence between preset defaults and project/runtime configuration. -- Added the `exhaustive` preset as the forward-looking name for the deepest built-in policy while retaining `research` compatibility throughout the 0.x series. -- Added per-run `ExecutionPolicy` scoping so concurrent analyses can use different limits without mutating process-global state. +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. -#### Planning and reusable execution state +### Highlights -- Added a versioned execution-planner contract with structured per-module status, reason, resource class, dependency, explicit-disable, and effective-disable metadata. -- Centralized built-in mode-to-module policy in the planner and made `analyze()` consume the same policy so planning and runtime cannot drift on mode disables. -- Added dependency propagation so disabling or invalidating an upstream module blocks dependent work instead of falsely advertising it as runnable. -- Added topologically ordered execution stages and a flattened runnable-module order as the scheduling contract for progressively planner-controlled execution. -- Added a per-run `AnalysisContext` with a thread-safe exactly-once intermediate cache, authoritative fact registry, named reusable bounded samples, deterministic seed, and metadata-only provenance. -- Updated `framevitals.plan()` to reuse one context for column roles, dataset signals, execution-budget derivation, and planner construction, and to expose context/cache/sample provenance without serializing raw sample values. +- 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 From 53b022d6d6ce04e9b7c2340b0fe27df9efae2bbc Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:26:41 +0530 Subject: [PATCH 47/52] docs: point examples at v0.3.0 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 5d8389f4493b29740b80eb586cbdb170d2d6f93f Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:26:48 +0530 Subject: [PATCH 48/52] release: bump Rust bridge to 0.3.0 --- rust/framevitals-py/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From c04446f1e258025896449a61b992f1d9a1f61fd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:57:29 +0000 Subject: [PATCH 49/52] chore: sync Cargo.lock for 0.3.0 --- rust/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 3e11c9f02f024e7fe16101448b88cce209c6749a Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:28:20 +0530 Subject: [PATCH 50/52] chore: remove one-shot release lockfile sync --- .github/workflows/release-lock-sync.yml | 35 ------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/release-lock-sync.yml diff --git a/.github/workflows/release-lock-sync.yml b/.github/workflows/release-lock-sync.yml deleted file mode 100644 index 17a1e2a..0000000 --- a/.github/workflows/release-lock-sync.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Sync 0.3.0 Cargo lockfile - -on: - push: - branches: ["release/0.3.0"] - paths: - - "rust/framevitals-py/Cargo.toml" - -permissions: - contents: write - -jobs: - sync-lockfile: - runs-on: ubuntu-latest - steps: - - name: Checkout release branch - uses: actions/checkout@v6 - with: - ref: release/0.3.0 - - - name: Refresh workspace lockfile from release manifests - run: cargo check --manifest-path rust/Cargo.toml --workspace - - - name: Commit lockfile if changed - shell: bash - run: | - if git diff --quiet -- rust/Cargo.lock; then - echo "Cargo.lock already synchronized." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add rust/Cargo.lock - git commit -m "chore: sync Cargo.lock for 0.3.0" - git push origin HEAD:release/0.3.0 From 62351a98847b42db1e5d2fe1c52b20710019119e Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:32:15 +0530 Subject: [PATCH 51/52] chore: open protected-main 0.3.0 release PR --- .github/workflows/open-0.3.0-release-pr.yml | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/open-0.3.0-release-pr.yml diff --git a/.github/workflows/open-0.3.0-release-pr.yml b/.github/workflows/open-0.3.0-release-pr.yml new file mode 100644 index 0000000..7436c13 --- /dev/null +++ b/.github/workflows/open-0.3.0-release-pr.yml @@ -0,0 +1,32 @@ +name: Open 0.3.0 release PR + +on: + push: + branches: ["release/0.3.0"] + paths: + - ".github/workflows/open-0.3.0-release-pr.yml" + +permissions: + contents: read + pull-requests: write + +jobs: + open-release-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v6 + with: + ref: release/0.3.0 + persist-credentials: false + + - name: Open bot-authored release pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr create \ + --repo parthdongre/FrameVitals \ + --base main \ + --head release/0.3.0 \ + --title "release: FrameVitals 0.3.0" \ + --body "Final FrameVitals 0.3.0 release candidate. Versions are synchronized across Python and Rust, Cargo.lock is regenerated, the changelog is finalized, CodeQL reports no new alerts, and the release candidate passed the core/native/interoperability/frontend/package-quality gates. This PR is automation-authored so the repository owner's approval can satisfy the protected-main review rule without bypassing branch protection. develop/august remains a long-lived branch." From 28e29a95c32eecea874e0a24c58c7cddf4be78b9 Mon Sep 17 00:00:00 2001 From: Parthozy <163404890+parthdongre@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:33:06 +0530 Subject: [PATCH 52/52] chore: remove temporary protected-main PR workflow --- .github/workflows/open-0.3.0-release-pr.yml | 32 --------------------- 1 file changed, 32 deletions(-) delete mode 100644 .github/workflows/open-0.3.0-release-pr.yml diff --git a/.github/workflows/open-0.3.0-release-pr.yml b/.github/workflows/open-0.3.0-release-pr.yml deleted file mode 100644 index 7436c13..0000000 --- a/.github/workflows/open-0.3.0-release-pr.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Open 0.3.0 release PR - -on: - push: - branches: ["release/0.3.0"] - paths: - - ".github/workflows/open-0.3.0-release-pr.yml" - -permissions: - contents: read - pull-requests: write - -jobs: - open-release-pr: - runs-on: ubuntu-latest - steps: - - name: Checkout release branch - uses: actions/checkout@v6 - with: - ref: release/0.3.0 - persist-credentials: false - - - name: Open bot-authored release pull request - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr create \ - --repo parthdongre/FrameVitals \ - --base main \ - --head release/0.3.0 \ - --title "release: FrameVitals 0.3.0" \ - --body "Final FrameVitals 0.3.0 release candidate. Versions are synchronized across Python and Rust, Cargo.lock is regenerated, the changelog is finalized, CodeQL reports no new alerts, and the release candidate passed the core/native/interoperability/frontend/package-quality gates. This PR is automation-authored so the repository owner's approval can satisfy the protected-main review rule without bypassing branch protection. develop/august remains a long-lived branch."