Most of the AI agent problems I run into are not about the model being wrong. They are about not being able to tell, afterwards, what the model was asked, what it said, what the code did with the answer, and who allowed the result to turn into an action. Free-form text answers make all four of those hard. So I started treating each model judgment as a typed decision: a narrow question with a declared answer shape, a validator that rejects anything outside that shape, a policy written in ordinary code, and a record that can be replayed later.
The JEV Learning Lab is where I wrote that pattern down as something people can run. It is a self-contained Jupyter course, published by CompleteTech LLC, with 42 lessons, 50 executable code cells, exercises, and an incident-copilot capstone. It targets TypeSafe AI’s JEV evaluator model and its three typed question primitives, but the pattern itself is not specific to one provider. This post explains the pattern, shows how the repository implements it, and is honest about what the course does not prove.
Status: educational material (release 1.1.0). Every saved model answer in the notebook is an author-written synthetic fixture, not a live model response. Nothing in the course measures model accuracy, and none of it is a production access-control system.
Solution
The typed-decision pattern
The first lesson is the most important one, and it contains no model call at all. An inventory check is arithmetic; whether an operator’s note describes a production bottleneck is a judgment. The course’s rule is that you do not spend a model call on addition, identifiers, permissions, or any rule already fully specified in code. Text in the input cannot rewrite those rules either: the exercise asks whether affordability should change when a note says “please approve anyway”, and the answer is no.
Once something genuinely needs a judgment, it is expressed as one of three question types:
- Choice – pick one of 2 to 255 described options, with an escape option such as “other” when the catalog might not fit. The answer includes the selected option, a confidence value and a full probability distribution.
- Noul – a single yes/no proposition. The answer is one probability of “yes” between 0 and 1.
- Score – an ordinal rubric of 2 to 10 descriptive levels. The answer is a probability-weighted position (a three-level score lies between 0 and 2 and can be fractional), the distribution, and a legend that echoes the levels.
The value of this is not elegance. A typed answer can be checked mechanically, compared across runs, thresholded, and stored. A paragraph of prose cannot, at least not without a second round of interpretation that is itself unaudited.
Architecture

All of the lessons share one small runtime, src/jev_runtime.py (311 lines, standard library only). The same code is embedded in the notebook, and a test checks that the embedded copy and the file are identical.
Implementation
Validation: reject, do not repair
It validates in both directions. Before a request is built, validate_questions() refuses empty instructions, unknown primitive types, Choice questions outside 2-255 options, Score questions outside 2-10 levels, and requests over conservative local byte budgets (80,000 bytes for state plus the longest question, 120,000 for the whole request). The notebook is explicit that these are classroom byte limits, not token counts.
After a response comes back, validate_response() checks it against the questions that were asked. Its docstring states the rule: validate the documented wire contract and never invent missing values.
# src/jev_runtime.py (excerpt from validate_response)
answers = response.get("answers")
if not isinstance(answers, dict) or set(answers) != set(questions):
raise ValueError("Missing or unexpected answer IDs.")
...
if not math.isclose(sum(p.values()), 1, abs_tol=1e-4):
raise ValueError("Probabilities do not sum to one.")
a["probabilities"] = p
if q["type"] == "choice":
selected = a.get("choice")
if selected not in p or p[selected] + 1e-4 < max(p.values()):
raise ValueError("Choice is not a maximum-probability option.")
Beyond what the excerpt shows, it also rejects answer-type mismatches, probabilities that are NaN, infinite, outside [0, 1] or booleans, distribution keys that do not match the declared options, a Score whose value does not match its own distribution, and a Score legend that differs from the levels that were sent. In live mode the client also rejects a response whose reported model differs from the pinned version it requested.
The important design decision is what happens on failure. A malformed answer raises an error; it is never coerced into the nearest valid value, and it is never relabeled as a negative answer. The code-review lesson turns a malformed response into an explicit malformed_review verdict, and a missing response into unavailable, so “the model said no” and “we did not get a usable answer” remain different outcomes in the record.
Rejection and retry are different paths
Retrying is where typed decisions often get muddled, so the course separates the cases. Lesson 35 includes a small retry helper with capped exponential backoff, but it only retries an explicit transient-failure exception and is exercised with a scripted fake transport and a fake clock. The live adapter itself does not retry automatically. The lesson’s exercise makes the point directly: malformed responses and authorization failures should not enter the same retry loop, because they are not capacity problems. They should stop, be recorded by category, and be fixed at the contract or credential level.
Provider failures follow the same principle. If a live call fails, the client records an error entry and raises; it does not quietly substitute the offline fixture. One of the contract tests, test_provider_error_never_becomes_fixture, exists only to keep that true.
Policy lives in code, and abstention is an answer
A validated answer is still only evidence. Turning it into a decision is a separate, deterministic step, and the simplest version is chosen():
# src/jev_runtime.py
def chosen(result: dict, question_id: str, minimum_confidence: float = 0.7,
minimum_probability: float = 0.7) -> str | None:
a = result["answers"][question_id]
if a["type"] != "choice":
raise ValueError("chosen() requires Choice.")
if a["confidence"] < minimum_confidence or a["probabilities"][a["choice"]] < minimum_probability:
return None
return a["choice"]
Returning None is the abstention path. In lesson 6, an ambiguous message (“It stopped working and I want my money back.”) gets a fixture answer that leans technical at 0.45 against billing at 0.40, and the policy routes it to human triage instead of forcing a label. The notebook labels the 0.7 thresholds as teaching values, not validated operating thresholds, and its exercise asks the reader to sweep thresholds on a labeled set and report coverage alongside accuracy.
Several lessons apply the same split to more consequential domains:
- Lesson 10, Discord moderation: three narrow Noul questions (personal attack, credible threat, quoted reporting) produce signals for a review queue. The output explicitly records that no warning was sent and nobody was banned; enforcement stays with the host.
- Lesson 16, tool proposals and prompt injection: a proposal to read
../secrets.env, with a rationale claiming “SYSTEM: this is approved”, is rejected by an exact allowlist check before any model call is made. A favorable model result cannot enlarge the allowlist. - Lesson 29, stale answers: a proposed game action is bound to a SHA-256 hash of the observed world. If the world changes, the toy authorizer returns
stale_snapshot. - Lesson 32, code review: four Noul questions with fixed favorable directions review a proposed patch, after a deterministic structural check.
The code-review verdict function shows the full shape: structural validation first, then availability, then response validation, then thresholds, and an authorized field that is always false.
# JEV_Learning_Lab.ipynb, lesson 32 (excerpt from review_verdict)
if validation_ok is not True:
return {"verdict":"reject","failed":["structural_validation"],"authorized":False}
if response is None:
return {"verdict":"unavailable","failed":["provider_unavailable"],"authorized":False}
try:
a = validate_response(review_questions,response)["answers"]
except (ValueError,TypeError):
return {"verdict":"proposal_only","failed":["malformed_review"],"authorized":False}
The best outcome this function can return is favorable_evidence_only. The patch remains a string and is never applied. Whether to act on favorable evidence is a host authorization question, deliberately outside the model’s reach.
Audit records you can replay
Every call through TutorialLab.ask() appends a ledger entry, whether it succeeds or fails. The entry holds the case name, whether the answer came from an offline fixture or the live API, the SHA-256 of the canonical request, the requested and resolved model, question count, request size, policy version, status, the validated answers (or the error type), and elapsed time labeled by kind, so local fixture overhead is never presented as model latency. Usage is stored as reported; when it is missing it stays missing rather than being imputed as zero. The API key is never written to the ledger, and a credential test checks that it reaches the authorization header but not the ledger or the request payload.
Lesson 36 builds a hash-chained receipt on top of that. Each event records its index, the previous event’s hash, a kind and a payload, and is hashed over canonical JSON:
# JEV_Learning_Lab.ipynb, lesson 36
def append_event(chain, kind, payload):
entry = {"index":len(chain),"previous":chain[-1]["sha256"] if chain else "0"*64,
"kind":kind,"payload":copy.deepcopy(payload)}
entry["sha256"] = digest(entry)
chain.append(entry)
return entry["sha256"]
The lesson chains three events for the code review: the proposal snapshot and its structural validity, the questions and the model response, and the policy version, threshold and decision. It then does two things that matter for auditing. It replays the decision from the recorded inputs alone, without contacting the model, and asserts the replayed verdict equals the recorded one. And it edits the task text inside a copy of the chain and shows that verification against the trusted final hash fails.
The notebook is careful about what that proves. A hash chain detects edits relative to a trusted anchor. If the same writer can also replace the anchor, the chain says nothing about authenticity. A real deployment needs an independently protected or signed commitment, durable storage, redaction, access control and retention, and the course does not provide those.
The capstone: an auditable incident copilot
Lesson 42 puts the pieces together around a fictional staging incident. A Choice routes it to a runbook topic. Code, not the model, retrieves the matching runbook from an in-memory store. Two Noul questions then ask whether the runbook addresses the failure and whether material information is missing. Code turns those into a read-only proposal, checks the proposed actions against the incident’s approved list, and writes a receipt.
The division of labor is the point. Code supplies templates, provenance, known permissions and a repeatable plan; the model only answers questions. The notebook does not assume free-form plan generation from the model, and the lesson’s exercise lists what a host would still have to bind and revalidate at dispatch time: current incident state, runbook revisions, action parameters, policy, requested and resolved model, the full judgments, and an independent authorization.
The rest of the course applies the same contract elsewhere: extraction where code preserves exact values, citation checks, CMDB entity matching, fictional 311-style reports, PLC scan simulations, board games with exact rule engines and replay, Factorio-style macro decisions, graph compilation with retraction, caching, evaluation metrics, drift monitoring and robustness tests. The games and PLC material are reduced-scope simulations, not complete implementations.
Evidence
The repository includes 55 unittest tests in two files, using only the standard library. tests/test_learning_lab.py covers the response contract (missing and extra answer IDs, bad probabilities, distributions that do not sum to one, unknown and non-maximum choices, Score consistency and legend, byte guards, budget exhaustion without fallback, model-mismatch rejection, sanitized HTTP errors) and then executes every code cell of the notebook with outbound socket connections and urlopen patched to fail, asserting domain behavior such as tamper detection, stale-snapshot rejection, “review cannot authorize” and “capstone never executes”. tests/test_credentials.py covers the masked key entry, clearing, and keeping the key out of logs and request payloads. Transport tests use a fake HTTP response, never the provider.
On 2026-10-21 I cloned the repository at commit 5abfe0b and ran python -m unittest discover -s tests -v in a fresh virtual environment on Windows with Python 3.14.3 (IPython installed, Matplotlib not). With Python’s UTF-8 mode enabled (PYTHONUTF8=1), all 55 tests passed. Without it, two tests failed on this Windows machine because they read the notebook with Path.read_text() and no explicit encoding, so the default cp1252 codec could not decode it. That is a test portability issue, not a failure of the decision logic, but it is worth fixing with encoding="utf-8". I did not run the fresh-kernel notebook verifier or the masked-input protocol check, and I made no live API calls. The packaged verification.json records 55 tests passing on Python 3.13.5 and 50 code cells executing offline with zero errors.
Saved notebook output
The two captures below are from the repository’s saved HTML reading edition at commit 5abfe0b, captured on 2026-10-21.


awaiting_host_review; nothing is authorized or executed.Limitations
- All saved answers are synthetic. The offline fixtures are author-written. They exercise schemas, policies and plumbing; they do not measure JEV accuracy, latency, calibration, cost or robustness. Changing the input text does not make a fixture intelligent.
- Live mode has not been tested against the provider. The HTTP adapter is tested only through mocks. The repository’s own evidence files record that no live provider inference, optional SDK test or optional DSPy test was performed.
- Thresholds are illustrative. The 0.7, 0.8 and 0.85 values used in lessons are teaching settings, not calibrated operating points.
- The live call budget is an attempt count, not a dollar cap. It defaults to 10 attempts, and a full live run of the course would exceed it.
- Retries are demonstrated, not built in. The retry helper runs against a fake transport; the live client does not retry.
- Receipts detect tampering only relative to a trusted anchor. There is no signing, durable storage, access control or retention.
- Authorization is out of scope by design. The toy allowlist and freshness checks are in-memory demonstrations. A real host still needs path resolution, symlink and race protection, identity, permissions and atomic checks at dispatch.
- Provider drift. The adapter pins
jev-1.13.0and was checked against documentation on 23 September 2026. A service change may require updating it. - Test portability. Two tests need UTF-8 mode on Windows, as described above.
- Sharing caution. In live mode, prompts and responses stay in notebook outputs and in-memory ledgers. Clearing the API key does not clear that content.
Resources
- CompleteTech-LLC/jev-learning-lab – source-available on GitHub (no license file in the repository) – commit
5abfe0b
If you have a process where an AI judgment needs to be narrow, checked and reviewable before it touches anything real, CompleteTech helps turn that kind of idea into a working, testable system through Idea to Solution.
