Repository navigation
Decision Models
Some cleaning steps are not parsing, they are judgment calls:
| Question | Why rules can't settle it |
|---|---|
Is BLR another name for Bangalore? |
Needs world knowledge |
Is Banglore a typo of Bangalore, or a different place? |
Spelling similarity is not meaning (Paris / Parish are one letter apart) |
Does none in an allergies column mean "missing" or "no allergies"? |
Depends on the column |
Which source column holds the schema's amount_inr? |
Headers like Amt (INR) are only weak name matches |
CleanFrame's rules answer these conservatively or not at all. With decide=, a
decision model answers each one as a typed question (pick one of these
options, or yes/no) with a probability. TypeSafe's Jev, an open "System One"
model, or any LLM through an adapter can play that role. CleanFrame applies only the answers it can stand behind and
freezes them into the recipe as ordinary ops. Replay never calls the model
again.
Jev decides once. Pandas executes. The recipe replays forever.
import cleanframe as cf
result = cf.clean(
"vendor_export.csv",
decide="typesafe/jev-latest", # or openrouter/typesafe/jev-1.14, systemone/<model>, rules, any LLM spec
llm_exposure="sample", # category questions send distinct values (see Privacy)
)
result.recipe.save("vendor.recipe.yaml")
print(result.decisions.summary()) # asked / applied / suggested / vetoedexport TYPESAFE_API_KEY=...
cleanframe clean vendor_export.csv --decide typesafe/jev-latest --llm-exposure sample
# Illustrative output (the numbers depend on your data and the model):
# ✓ Decisions → 23 asked (jev-1.14.0) · 2 applied · 1 suggested · 0 vetoed (bar p ≥ 0.9)
# applied City: BLR → Bangalore (p=0.97)
# applied City: Bombay → Mumbai (p=0.96)
# suggested City: Calcutta → Kolkata (p=0.81)decide= |
What answers | Key (each backend reads only its own) |
|---|---|---|
typesafe/jev-latest |
TypeSafe's API (https://api.typesafe.ai) |
TYPESAFE_API_KEY (TYPESAFE_BASE_URL to override the URL, as in TypeSafe's SDK) |
openrouter/typesafe/jev-1.14 |
Jev through OpenRouter's System One route | OPENROUTER_API_KEY |
systemone/<model> |
Any self-hosted Jev-compatible server (imajev, Laya, …) at SYSTEMONE_BASE_URL
|
SYSTEMONE_API_KEY (optional) |
rules |
CleanFrame's own heuristics, offline: a dry run and the benchmark baseline | none |
any LLM spec (anthropic/claude-sonnet-4-6, ollama/qwen3, …) |
The LLM, through the same typed questions | the provider's usual key |
an object with .decide(state, questions)
|
Your own backend | — |
Jev-compatible backends speak TypeSafe's System One contract
(POST /v1/systemone with model, state and named questions). CleanFrame
implements it with the standard library, so no SDK is installed. jev-latest follows
TypeSafe's newest release. To get the same answers next time, pin a version that
GET https://api.typesafe.ai/v1/models lists (TypeSafe's SDK shows pins such as
jev-1.13.0). OpenRouter names the model typesafe/jev-1.14.
Check the first real run. A backend error (a wrong model name, a bad key) degrades to the
rules-only plan with a warning. Run once with --no-llm-fallback --json and confirm that
decisions.answered is above 0.
LLM backends state probabilities that are not trained to be calibrated, so they
are recorded as calibrated: false. Prefer a decision model when you lower
decision_threshold.
| Question | Type | Asked when | Applied as |
|---|---|---|---|
| Is value V another name for one of the column's other values? | choice (+ "distinct") | Category columns whose values repeat; the most common value is never asked about | normalize_values |
| Are variant and canonical the same category? | yes/no | Every typo merge the rules made (similarity ≥ 0.86, one edit apart, rare vs frequent, no +/-/# difference) |
confirms or vetoes the merge |
| Does token stand for missing data here? | yes/no | Ambiguous null tokens (none, nil, -, --, ?, unknown) that rules never convert |
to_na |
| Which source column holds target field T? | choice (+ "none") | Weak or missing schema matches | rename_to |
Dates and numbers are not asked. Decision models are documented to be weak at arithmetic and dates, so those stay with CleanFrame's deterministic parsers.
-
Changing data needs
p ≥ max(mode threshold, decision_threshold)(0.9 by default; strict mode's 0.85 never lowers it). This covers merging an alias, turning a token into a missing value and renaming a column onto a schema field.cf.cleanruns the recipe immediately, so the bar is the gate. -
Below the bar it is a suggestion. The answer is recorded in
recipe.meta.decisionsand as adecision_suggestionissue, and it is never executed. A human can copy it into the recipe. -
Undoing a rules merge needs only
p > 0.5. A confident "no, these are different" keeps two spellings apart, which cannot corrupt data. Confirming every typo merge in a column lifts that fix to 0.9 confidence, so it survivesstrict. - For a choice, CleanFrame gates on the smaller of the option's probability and the model's reported confidence.
- A group of aliases (
BLR,Bengaluru,Bangalore) takes the most frequent spelling as canonical (the category detector's rule), never the direction of an answer.
columns:
City:
ops:
- strip_whitespace
- normalize_values: {Banglore: Bangalore} # rules (typo merge, confirmed)
- normalize_values: {BLR: Bangalore, Bombay: Mumbai} # decisions
meta:
decisions:
model: jev-1.14.0
calibrated: true
threshold: 0.9
asked: 23
answered: 23
applied: 2
suggested: 1
vetoed: 0
records:
- {kind: category_merge, column: City, subject: Banglore -> Bangalore, answer: 'yes', p: 0.98, outcome: confirmed}
- {kind: category_alias, column: City, subject: BLR, answer: Bangalore, p: 0.97, outcome: applied}
- {kind: category_alias, column: City, subject: Bombay, answer: Mumbai, p: 0.96, outcome: applied}
- {kind: category_alias, column: City, subject: Calcutta, answer: Kolkata, p: 0.81, outcome: suggested}Records keep everything that changed data, was suggested, vetoed or confirmed. The confident "this is a distinct value" answers are counted but not listed.
decide= follows llm_exposure:
| Exposure | Asked |
|---|---|
metadata (default) |
Schema questions (column names, semantic types, value patterns) and null-token questions (the token always comes from CleanFrame's fixed null vocabulary, never from free-form data) |
sample |
Also category questions, which send the column's distinct values and their counts, plus redacted examples as for the LLM planner. A CleanFrameWarning names the columns |
none |
Nothing. The backend is not called |
Category questions are only asked about columns whose values repeat (a profiled
categorical column, or two or more rows per spelling on average). A column of
mostly-unique names is never sent. Plain http:// to a non-local host warns,
because questions would travel unencrypted.
-
max_decisions=200(CLI--max-decisions) caps the questions per run. Skipped questions are noted inmeta.decisions.notes. - Questions go 8 per request. A
429/5xxis retried twice with backoff (honouringRetry-After, at most 10 s per wait). - A backend failure warns and continues with the rules-only plan for the
remaining questions; the error is kept in
meta.decisions.error.llm_fallback=False(CLI--no-llm-fallback) raises aDecisionErrorinstead. -
decide=cannot be combined withllm=yet: the LLM planner writes the whole recipe and would discard the decisions. - Multi-sheet workbooks: pass
--sheetto use--decideon one sheet. - The questions are deterministic: the same data (in any row order) produces the same questions. The answers are the model's, so pin its version.
The benchmark runs these exact questions over hand-labelled
messy columns and reports accuracy, safe-apply precision (of the answers that
would change data at the 0.9 bar, how many are right), calibration, cells fixed
vs corrupted, latency and cost per 1,000 decisions. Run it on your own data before
lowering decision_threshold.
from cleanframe.decisions import (
get_decision_client, # spec -> client
refine_issues, # the decision pass, if you drive the pipeline yourself
Question, Answer, DecisionResponse, # write your own backend
SystemOneClient, LLMDecisionClient, RulesDecisionClient,
)A custom backend is any object with model, calibrated and
decide(state, questions) -> DecisionResponse. Each Question carries a
context dict (kind, column, value, options) that is never sent over the wire.