Runtime validation and static checking preserve analytical intent as research code gains users, contributors, and AI agents.

The Analysis Ran Under The Wrong Assumptions
A research engineer is preparing adjusted performance metrics for a model review. The analysis compares how a clinical prediction model behaves across patient groups after accounting for differences in their risk distributions. The output will go into a governance meeting the next morning, so the engineer starts from the configuration used in the previous reporting cycle, changes the threshold, adds bootstrap intervals, and runs the pipeline.
The configuration looks ordinary. The threshold is written as 30, meaning 30 percent to the person who edited the YAML file. The bootstrap alpha is 5, following the same percentage convention. One metric is named ATPR rather than the package’s canonical aTPR. The file parses. Each value is a valid Python object: two integers and a string.
In this loosely configured version of the pipeline, the run goes farther than it should. A threshold of 30 places every probability below the cutoff. Some intermediate tables are empty, while other summaries contain zeros that look possible for a small subgroup. The bootstrap setting fails later, inside interval calculation. The team first inspects the dataframe filters and the metric implementation because that is where the symptoms appear. Nothing in the configuration-loading step identified the point where the analytical specification had already become invalid.
The defect is larger than a missing if statement. Raw values crossed from an uncertain external representation into trusted computation without one explicit decision about what the program would admit. The dictionary could tell Python that alpha existed. It could not establish that 0 < alpha < 1, that the threshold used probability units, or that the requested metric belonged to the supported vocabulary.
For a shared analysis pipeline, that distinction changes the repair. The team needs a boundary that rejects an invalid specification before any dataframe is filtered or any interval is calculated. It also needs an internal representation that no longer asks every computational function to reinterpret the same raw dictionary.
Useful Flexibility Becomes Costly When The Contract Travels
Research Python begins with good reasons to stay loose. During exploration, the method changes faster than its interface. A dictionary is easy to inspect in a notebook. A function with config=None is easy to call. A tuple return is quick to unpack, and a free-form string avoids defining an enum for an option that may disappear tomorrow.
Those choices optimize the work that matters at that stage: getting a method into executable form, checking whether an idea is promising, and adapting to scientific libraries whose own interfaces may be dynamic. Adding a model class to every temporary object would often slow the experiment without improving it.
The pressure changes when the representation travels. A configuration moves from one notebook into a command-line job. A result tuple gains a fourth value. Another researcher calls the function from a different module. A scheduled analysis uses a file written six months earlier. An AI agent updates one branch while inferring valid states from examples elsewhere in the repository.
At that point, local readability and global enforceability separate. The original author may find the loose function perfectly clear, while a typed version gives the contract stable names:
|
|
A new contributor to the first version still has to discover which columns data needs, which keys are required, which strings are accepted, whether missing cutoffs are valid, and what the function returns. That knowledge may be distributed across a notebook, docstring, test fixture, Slack message, and downstream key access. The interface is readable only after the contributor reconstructs the project around it.
The names do not document the method by themselves, and AnalysisDataset cannot magically prove the schema of a pandas dataframe. They do create stable places for the project to state its expectations. Type discipline reduces the amount of project knowledge that must be transferred socially. A team should apply it first where exploratory values have become shared interfaces: public functions, configuration, stage outputs, and results that other code must consume.
Runtime Validation Decides What May Enter
Python remains dynamically typed at runtime. An annotation such as alpha: float does not stop a caller from passing an integer, a string, or a value outside the meaningful range. Static analysis can flag some incompatible calls when it can see them, but a YAML file and a CLI argument do not pass through the type checker.
Pydantic is useful at this boundary because construction can combine representation checks with domain admissibility. metrics-adjuster, a research package for adjusted group-aware clinical prediction metrics, uses frozen Pydantic models for its analytical configuration. Its bootstrap settings include constraints like these:
|
|
This model establishes several different facts. alpha has a numeric representation, its admitted value lies strictly between zero and one, and the resulting object cannot be reassigned field by field. The interval procedure may still be implemented incorrectly, and a scientifically inappropriate confidence level can still pass. The validator’s authority is narrower: values such as 5 and -0.1 cannot enter the computation as valid alpha levels.
The top-level configuration adds rules that involve relationships among fields:
|
|
A primitive type cannot express that rule. Empty tuples are valid tuples, but this analysis has no defined cutoff calculation when both strategies are absent. The same configuration uses a MetricName enum to define the supported metric vocabulary and field validators to keep quantiles and thresholds within their admitted ranges.
This is the practical difference between type validity and domain admissibility. A float is only a representation. A probability range, a supported metric name, and a required relationship among cutoff fields are parts of the analytical contract. Centralizing those decisions in construction lets the computational core accept MetricConfig instead of scattering defensive checks through calibration, weighting, bootstrap, and reporting functions.
Parsing Policy Belongs To The Interface
Runtime validation is sometimes described as strictness, though that word hides an important design choice. External interfaces do not all speak the same language. A CLI receives text by definition. A TOML parser returns ordinary Python values. A persisted analytical artifact may promise a precise schema. Treating all conversions as equally safe would ignore those differences.
tabdat-explore, a terminal-oriented data analysis tool, makes the policy visible in its configuration path. TOML enters as a generic mapping. The loader rejects keys outside the accepted set before constructing configuration state, which means a misspelling cannot silently fall back to a default. For one text-friendly option, it deliberately recognizes "on" and "off":
|
|
The conversion is safe because it belongs to the documented semantics of a text-native interface. Once parsed, the value enters a frozen Pydantic dataclass configured with strict=True. The trusted object contains a boolean; downstream code does not repeatedly decide whether "false", "no", 0, or an empty string should count.
That same coercion would be questionable in a persisted analytical artifact that promises real booleans. Accepting "off" there might conceal an upstream schema regression. Inside a domain object, repeated coercion would be a stronger warning: supposedly trusted state is still being interpreted.
Parsing converts an interface representation into the program’s representation. Validation decides whether the converted value is admissible. Teams should define both policies at each boundary, reject unknown fields near their source, and stop converting after values enter the trusted core.
Static Checking Preserves Relationships Inside The Program
Once values have crossed a runtime boundary, another problem begins. The program must preserve relationships among those values as functions call one another, branches return different states, and refactors update interfaces. This is where a static checker earns its place.
basedpyright evaluates annotated Python during development rather than enforcing values at runtime. Its useful diagnostics are often unresolved design questions in disguise: a possible None, an unknown generic argument, a string outside a Literal, an invalid call after a signature change, or an Any value spreading from a weakly typed dependency.
Consider a subgroup estimator that may lack enough observations:
|
|
The union does not decide policy. It forces callers checked by basedpyright to acknowledge that policy is needed. A reporting workflow might skip the subgroup and record a reason. A regulatory analysis might abort because every prespecified subgroup is required. An exploratory notebook might display a warning and continue. Without the explicit optional state, one caller may dereference a missing result while another invents an undocumented fallback.
Any deserves similar attention. It is sometimes unavoidable when a scientific package has incomplete stubs or returns a dynamically shaped result. It is also a loss of evidence: after a value becomes Any, many invalid attribute accesses and calls are accepted by the checker. A cast does not convert or verify that value at runtime; it records a programmer assertion. Broad casts and ignores can therefore hide the exact uncertainty the checker was meant to expose.
My projects have increasingly converged on static checking as a required development step, although the checker is not historically uniform. tabdat-explore currently runs basedpyright in standard mode alongside mypy. metrics-adjuster uses strict mypy, while risk-compose uses strict mypy and pyright. basedpyright is now the checker I generally reach for because its inference, editor feedback, and diagnostics around unknown types fit rapid check-and-revise loops. The engineering principle is larger than the brand: checker failures at stable interfaces should be resolved as design questions instead of suppressed as formatting noise.
A Trusted Core Can Be Simpler Than Its Boundary
Pydantic does not need to own every object after validation. Rich construction rules are valuable where uncertain values arrive. Inside the program, ordinary frozen dataclasses can be a better expression of trusted state.
risk-compose, a package for deterministic risk-adjustment scoring, uses this separation. Its core defines frozen, slotted dataclasses for subjects, diagnoses, scoring requests, validation issues, table artifacts, predictor artifacts, and score artifacts. A request resembles this simplified excerpt:
|
|
The types turn stage outputs into named artifacts instead of nested dictionaries whose keys and tuple positions have to be remembered. The scoring path can validate a request, derive predictors, apply coefficients, and return a structured result without carrying raw file mappings through every layer. Multiple frontends can prepare the same request and consume the same result.
Immutability narrows the set of state transitions a reviewer must consider. It does not make the scoring rules correct, and the dataclass constructor alone does not enforce every domain rule. risk-compose retains explicit validation that can collect structured issues and apply strict failure policy. Pydantic remains a dependency where schema-oriented boundaries need it, while the deterministic core uses small standard-library containers where they are sufficient.
The design rule is contextual. Use Pydantic when construction must interpret or validate uncertain runtime data. Prefer a dataclass when values have already crossed that boundary, invariants are simple, and the object mainly names trusted domain state. The result is a narrow trusted core rather than a validation framework spread across the whole computation.

The diagram is also a review map. Parsing policy belongs near parse; admissibility belongs near validate; methodological and numerical logic belongs in compute. When those responsibilities blur, a raw input defect can travel far enough to resemble an algorithm defect.
Scientific Python Still Has Weak Boundaries
The strongest objection to strict checking in research Python is practical: pandas, statsmodels, R bridges, and specialized scientific packages do not always expose types precise enough for the contracts a project cares about.
A pd.DataFrame annotation does not prove that outcome exists, that risk contains probabilities, or that two series share an index. A formula string cannot usually encode its required columns in Python’s type system. Some libraries return result objects whose attributes depend on the fitted model. Others lack stubs and propagate Any. Pretending those difficulties have disappeared only makes the type policy less credible.
The useful response is containment. An adapter can accept the weak external type, perform runtime checks and extraction, then return a small object the rest of the program understands:
|
|
The annotation on frame makes a modest claim. require_columns must establish dataframe conditions at runtime. The extraction functions isolate knowledge of the library’s dynamic result. LogisticFit gives typed application code a stable representation after the adapter.
Suppressions may still be necessary. They should remain local, name the missing evidence, and end at a narrowed return value. A broad ignore across the modeling layer allows uncertainty to spread into reporting and decision logic. The goal is a visible perimeter around what the checker cannot establish, not a fictional promise of zero uncertainty.
Explicit Contracts Give Humans And Agents The Same Advantage
A collaborator should not need the original author’s complete mental model before making a locally safe change. The same is true of a AI agent entering a repository through partial context, search results, and tool calls.
Explicit contracts help both for the same reason. A signature names inputs and outputs. An enum lists valid alternatives. A Pydantic model provides executable construction rules and focused validation errors. A closed union exposes cases that a caller must handle. Named result objects reveal which stage owns an artifact. The repository carries more of the context that would otherwise have to be inferred from scattered examples.
This changes review work. When a metric name becomes an enum member, a reviewer spends less time finding string spellings across callers. When an optional result is explicit, the checker identifies call sites that forgot the missing case. When configuration policy lives in one model, the review can ask whether the declared constraint is analytically correct rather than whether five functions implemented the same range check.
The effect on agent-assisted development is operational rather than futuristic. An agent can inspect the declared interface, make an edit, run basedpyright, exercise Pydantic validation, run behavioral tests, and present the remaining methodological question for review. The loop is inspect contracts, edit code, run machine checks, and then review what those checks have no authority to decide.
AI assistance increases the rate at which code can be produced. Static checking increases the rate at which inconsistent code can be rejected. Runtime validation and tests add different rejection signals. None of them can determine whether an estimand answers the research question or whether a clinical interpretation is defensible.
Interfaces should still be designed for human readers first. Agent-specific ceremony that makes the code harder for a colleague to understand has missed the shared mechanism. The useful codebase is locally legible, gives precise feedback after changes, and leaves scientific judgment with the people responsible for the analysis.
Four Checks Have Four Kinds Of Authority
The claim for explicit contracts becomes weaker when types, validation, tests, and scientific review are treated as interchangeable layers of “correctness.” Each answers a different question.

A program can satisfy one row and fail another. A configuration may contain valid probabilities and supported metric names while requesting an estimand inappropriate for the decision. A model adapter may return a perfectly typed LogisticFit after fitting a misspecified model. A test suite may reproduce a calculation that encodes the wrong missing-data policy. A scientifically defensible method can still be implemented with an unchecked optional value that breaks one reporting branch.
These failures imply different reviewers and different evidence. basedpyright can reject an unhandled None; it cannot choose the subgroup policy. Pydantic can reject alpha=5; it cannot decide whether bootstrap intervals answer the inferential question. Tests can compare results with hand calculations; they cover executions the team selected. Scientific review examines the assumptions and intended use, while depending on the software layers to implement those decisions consistently.
A consequential research pipeline needs all four forms of scrutiny in proportion to its obligations. The value of types is that they reserve more human attention for the contract and the method by automating checks of declared computational relationships.
Strictness Should Follow The Software’s Obligations
A disposable notebook and a regulated decision-support tool should not carry the same engineering policy. The difference is not whether one author writes “good Python.” It is how many people, runs, interfaces, and decisions rely on the code after the current experiment.
Incremental adoption can follow the failure modes already visible in a project. Start by annotating public functions and stable module boundaries, because that is where collaborators reconstruct the most context. Replace free-form options with Literal values or enums when misspellings create silent branches. Introduce named result objects when tuple positions or dictionary keys have become part of an informal API. Validate configuration and external inputs when bad values can travel into expensive computation.
Then make optional states explicit, isolate weakly typed dependencies behind adapters, and watch for Any crossing multiple layers. Add basedpyright to the standard development command and CI once the team can treat its output as required evidence. Strictness can increase as warnings become localized. Casts, ignores, and missing stubs should remain visible items to review rather than permanent holes hidden in configuration.
That progression preserves the flexibility of early research. It also defines a direction of travel. Code that becomes shared, reusable, scheduled, public, agent-maintained, or decision-supporting accumulates obligations. Its important assumptions should move out of individual memory and into interfaces that can be inspected and enforced.
Return to the adjusted-metrics run from the opening. The revised path parses percentage-oriented user input according to a documented policy, constructs a validated MetricConfig, converts it into immutable trusted state, runs checked computation, and returns structured results. basedpyright checks the declared relationships among those internal objects. Tests exercise expected behavior and hand calculations. Researchers still review the metric definition, subgroup policy, uncertainty procedure, and interpretation before the output reaches governance.
The analysis can still be wrong. What changes is where certain classes of wrongness become visible and how far they are allowed to travel. Pydantic gives uncertain runtime values a gate. Static checking keeps declared relationships intact after the gate. Tests probe behavior, and scientific review retains authority over the method.
As implementation becomes cheaper, implicit contracts become more expensive. The goal is to keep Python’s productive flexibility while making the assumptions that matter visible before a collaborator, a AI agent, or a scheduled run has to discover them through a plausible result produced under the wrong specification.