hunch
Plain functions on Jev. Lists in, lists out.
Jev is TypeSafe's System One model. You send it some state and a typed question, and it sends back a label, a score, or a yes/no probability instead of a paragraph. I think it's the most useful thing to happen to "AI in a for loop" in a while. Calling it raw is fiddly, though: build a state object, build a question object, dig the answer out of the response. hunch is the version I wanted, where each of those is one function call and you can hand it a list or a pandas column instead of one thing at a time.
The rule the whole library follows: Jev decides, your code owns the workflow, and if an LLM is involved at all it only gets to propose candidates.
import hunch
hunch.classify("This product is amazing!", ["positive", "negative", "neutral"])
# 'positive'
hunch.classify(df["JOB_TITLE"], ["Sales", "Engineering", "Marketing"])
# Series of labels, same index
hunch.score("Critical system failure", ["cosmetic", "degraded, workaround exists", "down for everyone"])
# 1.87 (position on the scale, 0 .. n-1)
hunch.check("BUY NOW!!!", "is unsolicited advertising")
# True
drafts = hunch.generate(str, n=20, instructions="tweets introducing hunch") # an LLM writes
hunch.pick(drafts, "most likely to make a Python developer install it") # Jev chooses
# 'Most of my "AI" code was a for loop around a prompt and a JSON parser. ...'
Install
pip install hunch-jev
Python 3.10+. Set TYPESAFE_API_KEY in your environment, or call hunch.configure(api_key=...) at startup. Keys don't belong in source files.
Verbs
| Verb | Jev primitive | Returns |
|---|---|---|
classify(data, labels, multi_label=False, instructions=None) |
Choice, or one Noul per label | label, Enum member, or list of labels |
score(data, levels, instructions=None) |
Score | float position on the scale, or {dim: float} |
check(data, statement, criteria=None, threshold=0.5) |
Noul | bool, or {name: bool} |
pick(candidates, instructions) |
Choice over the candidates | the winning candidate |
rank(candidates, dimensions, levels, weights=None) |
Score per dimension | Ranked rows, best first |
generate(target, n=1, instructions=None) |
your LLM, validated by pydantic | target or list[target] |
ask(data, {name: Classify(...) | Rate(...) | Check(...)}) |
all of the above, one request per item | dict per item, or a DataFrame for a Series |
where(data, statement, columns=None, threshold=0.5) |
Noul per row, then filter | the rows that match, strongest first |
Hand any verb one item and you get one answer back. Hand it a list, a tuple, or a pandas Series and you get the same container back, same length, same index. Hand it a DataFrame and each row is the thing being judged, so Jev sees every column, and the answers come back on the frame's index ready to join. Duplicate values are only asked once, and the distinct ones run in parallel across max_workers threads. All of them take context= for extra state that should ride along with the input, and client= if you don't want the default. There's an _async twin of each, too.
labels can be a plain list, an Enum class (you get members back, not strings), or a dict of label to description when the names alone are ambiguous. On score and check, instructions can be a dict of name to question. Those go out as one request per item and you get a dict back per item, which is how you score five dimensions without five round trips.
Semantic WHERE
where is the filter you wish SQL had. It keeps the rows for which a statement holds and returns them strongest match first. On a DataFrame, Jev reads every column unless you pass columns=; the whole row comes back either way.
df.hunch.where("probably likes cats")
df.hunch.where("is a decision-maker at a company that sells to enterprises", columns=["title", "company"], threshold=0.7)
detail=True returns all rows with match and match_p columns so you can draw your own line.
df.hunch
Every verb is also on a .hunch accessor for DataFrames and Series, so it reads left to right in a notebook:
df["review"].hunch.classify(["positive", "negative", "neutral"])
df.hunch.ask({"vibe": Classify([...]), "red_flag": Check("...")})
df.hunch.rank({"hook": "...", "clarity": "..."}, levels=[...])
best = df.hunch.pick("the best first date for the person in context", context={"looking_for": ME})
Several questions, one request
When you want more than one thing about the same data, ask sends every question in a single Jev request per item. Each question is a small spec with the same arguments as its verb. A Series comes back as a DataFrame on the same index, so it joins straight onto your frame.
from hunch import ask, Classify, Rate, Check
answers = ask(prospects["JOB_TITLE"], {
"function": Classify(functions, FUNCTION_INSTRUCTIONS),
"seniority": Classify(seniorities, SENIORITY_INSTRUCTIONS),
"urgent": Check("this person should be contacted this week"),
"fit": Rate(["poor", "okay", "strong"], "How well does this title fit an enterprise sales motion?"),
})
prospects = prospects.join(answers)
With a Series or DataFrame, detail=True spreads each answer into columns instead of handing you objects: fit, fit_level, fit_confidence, fit_shape for a score; label, label_p, label_confidence, label_shape for a classify; check, check_p for a check. No lambdas to unpack anything.
pick on a Series or DataFrame returns the winner's index label, so df.loc[best] is the row. rank returns a DataFrame with composite and one column per dimension, sorted best first, on the same index.
detail=True
The bare return is the answer. detail=True returns the whole distribution:
| Verb | Detail type | Fields |
|---|---|---|
classify |
Answer |
.label .p .probabilities .confidence .shape .top2 .on() |
classify(multi_label=True) |
MultiAnswer |
.labels .probabilities .threshold |
score |
Rating |
.score .level .normalized .probabilities .legend .confidence .shape .on() |
check |
Feeling |
.p .threshold, truthy at threshold |
pick |
Pick |
.winner .ranked .confidence .shape .on() |
.shape is a judgment about the distribution, and the cutoffs are yours, not Jev's:
| Shape | Meaning |
|---|---|
sure |
One option dominates |
split |
Two options are close |
unsure |
Flat or weak evidence |
level = hunch.classify(title, ["IC", "Manager", "Director"], detail=True)
seniority = level.on(
sure=level.label,
split=lambda: hunch.classify(title, level.top2), # rematch the top two
unsure="review",
)
Cutoffs live on ShapePolicy. One thing worth internalizing: confidence measures how peaked the distribution is, not whether the label is correct. A confidently wrong answer is still confident. Changing the policy never re-runs inference, because the cache stores the raw distribution and the shape is computed on the way out.
Generate, rank, pick
This is the part where an LLM is allowed in the room. It writes the candidates. Jev scores them and picks. The weights stay in your code, so re-ranking after you change your mind costs nothing.
import hunch
hunch.configure(llm=hunch.openrouter(), cache="~/.cache/hunch")
tweets = hunch.generate(str, n=20, instructions="Tweets introducing hunch to Python developers", context=README)
ranked = hunch.rank(
tweets,
{"hook": "How strong is the first line?", "clarity": "How clearly does it say what hunch does?", "specific": "How concrete, not generic, is it?"},
levels=["weak", "okay", "strong", "excellent"],
weights={"hook": 2, "clarity": 1, "specific": 1},
)
finalists = [row.item for row in ranked[:5]]
winner = hunch.pick(finalists, "the tweet most likely to make a Python developer install hunch")
generate accepts str, int, dataclasses, TypedDicts, pydantic models, list[str], and any other type pydantic can validate. hunch.openai, hunch.cerebras, and hunch.openrouter are OpenAI-compatible adapters; pass llm= on configure() or on generate().
Client
jev = hunch.Client(api_key=..., model="jev-latest", cache="~/.cache/hunch", max_workers=8, policy=ShapePolicy(...))
hunch.classify(x, labels, client=jev)
jev.usage # calls, cache hits, tokens, model
hunch.configure(...) takes the same arguments and sets the default used when client= is omitted. cache= writes raw Jev answers to disk keyed by state and question, so re-running a script over the same data is free.
Big columns get a progress bar. Any call that needs 10 or more requests shows one, counting requests rather than rows, so it already reflects dedupe and cache hits. generate shows an elapsed timer while it waits on the LLM. progress=True forces it on, progress=False turns it off.
Examples
Each is a single file with the data inline, so you can run it as-is. The first three need only TYPESAFE_API_KEY. The last two also draft with an LLM, so they want an OpenRouter key.
| File | Shows |
|---|---|
find_angry_reviews.py |
check over a column as a boolean mask, ranking by probability, several checks in one request |
classify_job_titles.py |
classify with Enums, detail=True, and .on() routing sure / split / unsure with a rematch |
triage_tickets.py |
score on two scales in one request, multi-label classify, paging policy kept in code |
introduce_hunch.py |
generate 20 tweets with an LLM, rank them on weighted dimensions, pick the winner |
organize_downloads.py |
An LLM proposes a folder taxonomy, classify assigns every file, the script moves them. --dry-run prints the plan |
What this is not
Jev does not invent labels. Whatever you pass as labels is the entire set of allowed answers, and that constraint is the point. generate is the one place invention happens, and it has no tools and takes no actions. If you want open-ended writing or a multi-step agent, this is the wrong library, on purpose.
License
MIT. Jev and TypeSafe are typesafe.ai; this library is not affiliated.
Release files for hunch-jev 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hunch_jev-0.5.1.tar.gz | 405.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hunch_jev-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 428.9 kB
Release files / hunch_jev-0.5.1.tar.gz
| Download URL | hunch_jev-0.5.1.tar.gz |
|---|---|
| Size | 405.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0264527231810b6b4a27568398f6d8a3fa0cd438307ef56ac9d2684dc688ec82
|
|
BLAKE2b-256 checksum How to use checksums |
20fabdffc81f9d9b4653d8fc142ccc45ba2649c78ace2aeaa60873b93aa8310b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / hunch_jev-0.5.1-py3-none-any.whl
| Download URL | hunch_jev-0.5.1-py3-none-any.whl |
|---|---|
| Size | 24.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e57549634e44790abff41bcffe01c09b5d9c7c5274d3d40e640f33b7853f386b
|
|
BLAKE2b-256 checksum How to use checksums |
70bb84539e7ddba07a49bf3095b45b2d886fd39a60dc2d3c8d95906f955b510a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log