A Deterministic Compiler for Data

See the answer,
not a guess.

Upload a spreadsheet, ask a question the way you'd ask a person, and get back a real, checked answer instead of a script that might quietly be wrong. An AI figures out what to calculate, but it never writes or runs the code itself. That part is handled by the same deterministic Python every time.

Try the Live Demo
Watch this question get answered, below
"What is the average net worth of tech billionaires in the US compared to China? Show me a bar chart."
Dataset: forbes_billionaires.csv · 2,640 rows · 124 columns · Source: Forbes (public)
Built with Python LangGraph Pydantic v2 pandas numexpr Qdrant Streamlit
Profiler
Planner
Executor
Reporter

It reads before it answers.

Before Scrygent tries to answer anything, it looks at the data itself. Most tools hand a CSV straight to an LLM and hope it guesses the right column names and values. Scrygent profiles the dataset first, so the planner starts from facts instead of assumptions.

The Profiler runs in milliseconds and works structurally: it flags sequential IDs so they don't get mistaken for real numbers, builds regex skeletons to spot patterned columns like formatted IDs or names, and matches query tokens directly against categorical values, all before the prompt ever reaches the LLM.

Dataset Preview · forbes_billionaires.csv
rankpersonNamecountrycategoryfinalWorth
1Elon MuskUnited StatesTechnology250.0
2Jeff BezosUnited StatesTechnology195.0
3Bernard ArnaultFranceFashion187.0
4Mark ZuckerbergUnited StatesTechnology172.0
5Larry EllisonUnited StatesTechnology159.0
6Warren BuffettUnited StatesFinance148.0
7Bill GatesUnited StatesTechnology135.0
8Mukesh AmbaniIndiaDiversified118.0
rank int64 Sequential ID
personName object
country object · 77 unique Query Match
category object · 18 unique Query Match
finalWorth float64
source object Skeleton: "A, A"

Query-Specific Matches

category "tech" → "Technology"
country "US" → "United States"
country "China" → "China" (exact)

It plans. It doesn't script.

Asking an LLM to reason about your question and produce perfectly formed JSON in the same breath is asking for trouble. Scrygent splits that work into three passes, so the model never has to think about logic and syntax at the same time.

Pass 1 reads intent and pulls structurally similar past plans from the vector store as few-shot examples. Pass 2 applies query optimizations like filter pushdown, metric consolidation, and top-N entity routing. Pass 3 takes that optimized plan and emits it as a strict, validated Pydantic payload, locked behind json_mode so the output can only ever be well-formed JSON.

Pass 1 · Parser

Abstract Intent

First, the model just reads. No schema to worry about, no JSON to format, only what the question is actually asking for. It also checks the vector store for past plans that solved something similar, so it isn't starting cold.

Intent: Filter Technology billionaires in US and China, compute average finalWorth, render a bar chart.
Pass 2 · Optimizer

Execution Heuristics

With intent settled, this pass rewrites the plan to run efficiently. Filters move earlier so the dataset shrinks before any aggregation touches it, redundant steps get merged, and queries chasing extremes (biggest, smallest, top N) get routed down a faster path.

Filter Pushdown applied.
Conservation Invariant: params unchanged.
Pass 3 · IR Emitter

Strict JSON Binding

Only now does the plan become JSON. This pass translates it into strict Pydantic parameters, locked behind json_mode so the model's output vocabulary is physically restricted to valid tokens. Nothing malformed makes it through.

IR emitted. Ready for execution.
Constrained Re-Plan Loop. Sometimes the Planner needs more than the initial profile gave it. When that happens, it emits a single request_column_stats call and waits until the Executor enriches the profile with what it asked for. A has_replanned guard keeps this from looping: one mid-session augmentation per query, never more.
Compiled Intermediate Representation Verified
{
  "steps": [
    {
      "step_id": "step_1",
      "rationale": "Filter pushdown: Isolate US and China Technology billionaires to minimize data footprint before aggregation.",
      "tool_name": "filter_dataset",
      "parameters": {
        "filters": [
          { "column": "country", "operator": "in", "value": ["United States", "China"] },
          { "column": "category", "operator": "==", "value": "Technology" }
        ]
      },
      "required": true
    },
    {
      "step_id": "step_2",
      "rationale": "Generate a bar chart comparing finalWorth across the filtered countries.",
      "tool_name": "generate_plot",
      "parameters": {
        "plot_type": "bar",
        "columns": ["country", "finalWorth"],
        "title": "Avg Net Worth: US vs China (Tech)"
      },
      "required": true
    }
  ]
}

Deterministic code.
Zero hallucination.

Once a plan is verified, the Executor runs it against a handwritten suite of pure Python tools, nothing generated, nothing improvised. Watch the log on the left: step one runs clean, but step two hits a wall the LLM couldn't have predicted.

Transforming tools pass state through immutable temporary files instead of holding DataFrames in memory, and all row-wise math runs through numexpr with a wiped global dictionary, so there's no path to an eval() injection. When a step fails, as it does below, that failure doesn't crash the run. It becomes context for the correction loop explained to the right.

[✓] Step 0: Dispatching filter_dataset... 14ms
filters: country in ["United States", "China"], category == "Technology"
[!] Error: Filter returned 0 rows
No exact match for 'US' in column 'country'.
Did you mean one of these exact values: ['United States']?
[⚡] Triggering internal LLM Correction Chain...
Available columns injected into LLM context.
[✓] Parameters patched. Retrying step_1 (Attempt 2/3)... 312ms
Wrote transformed dataset to /tmp/scrygent_wrangle_8f92a.csv (rows: 341)
[✓] Step 1: Dispatching generate_plot... 8ms
Loading active dataset from /tmp/scrygent_wrangle_8f92a.csv
Image saved to /tmp/scrygent_plot_b349x.png
[✓] execution_status: complete 334ms total
🔒 Zero-Trust Math: All row-wise expressions are evaluated via numexpr with global_dict={}. No eval(). No code injection.
1 The LLM writes "US", a reasonable guess, but not what's actually in the column.
2 The filter runs, returns zero rows, and the engine catches it immediately.
3 difflib checks the real values and finds a close match: "United States"
4 The correction chain wakes up with that suggestion in hand and patches the JSON.
5 Retry 2 of 3 succeeds in 312ms, and the plan keeps moving.

The answer.
Not a narrative.

By the time the query reaches the Reporter, every number in it has already been verified. The Reporter's only job is to state that answer plainly before it says anything else.

It's prompted to fill a dedicated primary_answer field, built entirely from the tool outputs upstream. Everything else, the supporting observations below, comes after, and only from what was actually computed.

Tech billionaires in the United States average a net worth of $8.2B, compared to $4.1B for those in China.
  • The US cohort is 2× larger (247 individuals vs. 118), but the per-capita advantage remains significant even after normalizing for sample size.
  • China's Technology billionaires skew younger too (average age 52, versus 61 in the US), suggesting a faster wealth-creation velocity.
  • Both cohorts show right-skewed distributions; the median gap ($6.1B vs. $2.8B) is narrower than the mean, indicating a few US outliers inflate the average.
The Prime Directive. The answer comes first, always, because the schema requires it: the Pydantic model gives the LLM an isolated field for the direct answer, and nowhere else to put it. Everything that follows has to trace back to the verified JSON payload. There's no field for outside facts, so there's nowhere for a hallucination to hide.
Avg Net Worth: US vs China (Tech) Generated by Scrygent
0 2 4 6 8 Net Worth ($B) $8.2B $4.1B United States China

Built for real traffic.
Not just happy paths.

A demo only has to work once. A tool people actually rely on has to survive rate limits and dropped connections, and shouldn't be rebuilt from scratch the day one LLM provider stops being the right choice. These are the patterns that keep Scrygent running, and adaptable.

Custom Rate-Limit Pacer

Hit a real 429 mid-query and the whole run shouldn't die. A RunnableLambda catches it and backs off with deterministic jitter, while a ContextVar carries live cooldown status up to the UI without pulling a single Streamlit import into the core engine, so the engine stays usable outside Streamlit too.

Provider-Agnostic Architecture

The Planner and Reporter nodes never call Groq or OpenRouter directly, they call an LLM factory. Swapping which provider backs a given run is a configuration change, not a rewrite. Nothing hot-switches mid-query, but nothing is welded to one vendor either.

Serverless Memory

Every successful plan is worth remembering. It's embedded with FastEmbed and stored in Qdrant Cloud, so a future query that looks structurally similar can retrieve it as a few-shot example. The planner gets sharper with use, with no retraining and no server to maintain.