Canonical reference for humans and AI agents. Read it before changing code. User-facing detail lives in
docs/ and examples/.
KonfAI is a modular, fully-configurable deep-learning framework for medical imaging (Boussot & Dillenseger, 2025, arXiv:2508.09823). A model, its data pipeline, losses/metrics, optimizer/schedulers, augmentations, and the whole train/predict/evaluate workflow are described in YAML and mapped onto Python objects by a reflection engine, without editing code. The same engine drives a fifth workflow, dataset preparation (TRANSFORM): read a dataset, apply a chain, write it back out. The config is a complete, reproducible record of the experiment. KonfAI has produced top-ranking MICCAI-challenge results (SynthRAD, TrackRAD, CURVAS, PANTHER) across segmentation, registration, and synthesis.
Three pillars run through the codebase:
- Config-by-reflection.
apply_config(path)reads a callable's signature and builds its arguments from the YAML subtree it owns (@config("Key")), recursing into nested@configobjects. Resolved defaults are written back to the file, so a run leaves a fully-resolved config on disk. Reading a config mutates it. Each workflow builder reads its file insidestrict_config(root), which writes it once, when the build succeeds: a build that fails leaves the file byte-identical, except under--init(initializing()), which writes what resolved before the error. A key nothing bound reads is reported by path (TRANSFORM refuses, the other three warn), so everything a workflow reads from its file must be bound at construction, not lazily. - Lazy, patch-based imaging. A volume is never loaded whole into RAM on a route that can stream: data is read as overlapping patches (optionally streamed) and predictions are reassembled with overlap blending. Mandatory invariant. Three exceptions are declared. TRANSFORM's whole-volume route, taken only when a stage cannot serve a region: the plan names it
WHOLE-VOLUME, sizes it against the memory budget (Transform.working_multiple) and refuses the case when it does not fit. And the statistic pass of the TRAIN, PREDICTION and EVALUATION loaders (DatasetManager.warm_stream_statistics): aGLOBAL_STATstage whose input an earlier stage changes gets its statistic from one whole-volume read per case before the case streams, which the loader says. And PREDICTION's one-pass reader with TTA copies (SamplesDataset._loads_whole), which loads a case whose whole-volume pass fits the memory budget: a sweep would replay the chain for every copy. Never an implicitread_data(). - Declarative models. Networks are routed
add_modulegraphs, written as Python classes inkonfai/models/, or entirely as a.ymlvia the YAML model builder.
| Path | Role |
|---|---|
konfai/ |
Core package (config, data, network, metric, workflows, utils) |
konfai-apps/ |
Independent package konfai_apps (app management, HF repos, FastAPI server) with its own pyproject.toml, deps, and CI |
konfai-mcp/ |
Independent package konfai_mcp (FastMCP server exposing KonfAI to LLM agents) with its own pyproject.toml, tests, CI. On main since v1.6.0; published to PyPI by the release workflow. |
konfai-studio/ |
Independent package konfai_studio (FastAPI BFF + built React front: a chatbot web UI over konfai-mcp). Own pyproject.toml; the front (konfai_studio/web/*) is a CI npm build, not in git; wheel-only. |
konfai-scout/ |
Independent package konfai_scout (KonfAI Scout: a vision-language agent that inspects a segmentation, registration or synthesis result visually and reports localised defects with evidence and KonfAI Eval measurements). Own pyproject.toml, tests, published by the release workflow; depends on konfai alone. |
apps/ |
Ready-to-use model app bundles (excluded from the konfai wheel) |
examples/ |
Runnable Segmentation / Synthesis / Registration / Transform workflows, then the published apps (index: examples/README.md; assume CWD = the example dir) |
docs/ · tests/ |
Sphinx site · core test suite (tests/unit, tests/integration) |
konfai/models/ has two halves: models/python/<kind>/ (Python Network subclasses) and
models/yaml/ (the shipped declarative catalog, 14 models). models/python has no __init__.py on
purpose: it is a PEP 420 namespace package, and the wheel ships it via include = ["konfai", "konfai.*"]
with namespaces=true; the catalog *.yml ship via package-data. Changing either breaks the wheel
silently, so verify with a clean non-editable install, not an editable one.
Core modules worth knowing: utils/config.py (the reflection engine: read before any config change); utils/dataset/ (one module per storage backend SitkFile/H5File/OmeZarrFile/DicomFile/ItkTransformFile + the Attribute geometry sidecar + DataStream region writes); data/data_manager/ (groups, read order, samples, subsets, the per-workflow sources) + data/patching/ (stage, budget, sizer, sweep, blend, accumulate, grid, manager: the lazy patch index, DDP sharding, overlap-blended reassembly, SlabRegionStream) + data/materialize.py (CaseMaterializer: the TRANSFORM materialization engine over the manager's plan/replay API); data/geometry.py (grids/boxes/affine maps in world coordinates: pure numpy, crosses the mp.spawn pickle boundary) + data/sampling.py (coordinate producers + the torch gather; no SimpleITK); network/network/ (ModuleArgsDict/Network/ModelLoader/Measure across base, loaders, measure, network, model: the heart of the model system); metric/measure/ (Criterion = losses + metrics, one module per family); data/transform/ (one module per family, every stage re-exported by the package) + data/augmentation/ (base, spatial, color, intensity, placed); data/reduction.py (Reduction/Mean/Median/Std/Vote/Concat: a bare combine/reduction name resolves in this module) + data/case_reduction.py (the N-cases-to-one engine); trainer.py/predictor/ (output sink, ensemble, loop, workflow)/evaluator.py/transformer.py (the pipelines; transformer.py is the dataset-preparation one); api.py (the workflows as Python callables); export.py (ONNX export, extra export); config_schema.py (the JSON Schema of each workflow config, read off the constructors and published by the docs build: a new dict[str, X] whose keys name components is registered in its chains table, or the schema refuses their arguments); main.py (CLI); utils/{errors,model_builder}.py, utils/runtime/ (environment, logging, distributed).
Commands → config files. KonfAI is command-driven; five CLI states map to four YAML files:
| Command | File | Root key | Purpose |
|---|---|---|---|
TRAIN / RESUME |
Config.yml |
Trainer: |
Model + dataset + losses + augmentations + optimizer/schedulers + training params |
PREDICTION |
Prediction.yml |
Predictor: |
Load model(s), patch/TTA/ensemble inference, output post-processing |
EVALUATION |
Evaluation.yml |
Evaluator: |
Predictions vs ground truth → per-case + aggregate metric JSON |
TRANSFORM |
Transform.yml |
Transformer: |
Dataset preparation. Read a dataset, run a chain per case, Write the result. Plan first; an existing output is skipped, so a run resumes |
Each run writes a workspace keyed by train_name (name for TRANSFORM): Checkpoints/, Statistics/ (TensorBoard + the resolved-config snapshot), Predictions/, Evaluations/ (metric JSON), Transforms/ (per-rank logs opening with the plan, the config copy, outputs.json, but not the transformed data, which lands wherever each Write: points, in the user's own tree). Dataset/ is the input data directory, not a run output.
Python API. The workflows are also callables: konfai.transform / run_transform(dry_run=True) / evaluate / predict / train (api.py, lazily re-exported from konfai). Same engine, two spellings: a chain is a list of live stage objects or the equivalent dict tree. The contract: a designed refusal raises KonfAIError (only the konfai and konfai-apps CLIs catch it and exit), results come back structured (TransformResult/EvaluationResult), the KONFAI_* environment and the published per-rank memory budget are restored around every call, one workflow per process (lock), and a caller's config FILE is copied to scratch, so the write-back never lands on it (a model YAML named by a relative path is made absolute against the caller's file first, or the working directory for a tree; the scratch is released when the call returns).
Conventions. Arrays are channel-first [C,(Z),Y,X]; geometry/spacing is (x,y,z) (SimpleITK). A chain (transforms:, patch_transforms:, criteria) is a mapping keyed by class name (a repeated class is spelled module-qualified the second time) or a YAML list of one-entry mappings, which binds in order under occurrence keys (Clip, Clip#2, Clip#3); get_module drops the suffix when it resolves the class. Attribute geometry keys are Origin/Spacing/Direction. In Resample, a spacing/shape value ≤ 0 is the keep-this-axis sentinel (that axis keeps the source's own density/extent). The words with two meanings (group, fold, worker, workspace, bundle, Metric_TRAIN) are defined in docs/source/reference/glossary.md.
Network graph. add_module(name, module, in_branch=[...], out_branch=[...], alias=...) wires a string-keyed branch register (branch '0' = input; execution = insertion order). Named module outputs are referenceable in YAML: an outputs_criterions key is a module's dotted path like UNetBlock_0:Head:Softmax (the :/. separators are load-bearing). out_branch:[-1] marks a terminal/deep-supervision head; alias lists are positional and load-bearing for pretrained-weight remapping.
Runtime. Workflows run under run_distributed_app (utils/runtime/distributed.py): it builds the configured DistributedObject, sets the KONFAI_* env vars, forces KONFAI_CONFIG_MODE='Done', and runs one rank per GPU (or per --cpu worker): a single rank runs inline in the calling process (KONFAI_INLINE_SINGLE_RANK, default on), several are spawned, or the job is submitted to SLURM via submitit. Disk/log side effects are gated on global_rank == 0.
For the full config-key catalogue and a concrete end-to-end trace, read the docs/ config guides and examples/.
Every extension point is "subclass a base, reference it by classpath in YAML", with no core edits:
- Model: subclass
network.Network, build the graph in__init__viaadd_module. Referenceclasspath: module.MyNet, a localModel:MyNet, a.yml, ordefault|<Name>.ymlfor the shipped catalog. - Pretrained weights:
utils/pretrained.py:transfer_weights_by_execution_orderpairs weighted leaves in forward-execution order (no key map). It fills every target tensor or raises: a tensor held by a parent module (torch.nn.MultiheadAttentionownsin_proj_weightbeside itsout_projchild) or by a submodule the forward skips cannot be paired. Unreached source branches (nnU-Net deep-supervision heads) are ignored on purpose. - Loss / metric: subclass
metric.measure.Criterion;forwardreturns aTensor(loss) or a(value, dict)tuple (metric; consumersisinstance-branch). Attach underoutputs_criterions/metricsto a named module output. A config that leavesis_lossout takes the criterion's own role, written back: declaredefault_is_loss = Falsefor a score whose loss form must be asked for (SSIM, then1 - SSIMwith its gradient, read offas_loss) andloss_capable = Falsefor one that has none (PSNR:is_loss: trueis refused). Declarebatch_mean = Trueonly if the reported value and the loss are each the mean of one value per patch of the batch: a logged mean then weighs each batch by its patch count (validation over N ranks equals one rank); a pooled value (Dice's sums, a norm, asumreduction) keeps one per batch. Optional-dep criteria import lazily via_require_optional(...)and raise an actionableMeasureError, never a bare top-level import. Declareany_target_grid = Trueifforwardnever compares its target voxel to voxel (Mean,Gram): EVALUATION's shape check and geometry warning then leave the metric out. Declareresamples_target = Trueif it puts a target of another shape on the output's grid itself (Dice,FocalLoss): the shape check leaves it out, and the geometry warning takes it in when both shapes match, since it then compares voxel to voxel. - Transform: subclass
data.transform.Transform; implement__call__andtransform_shape()(must predict the output spatial shape exactly, because patch planning depends on it). Declareworking_multiple(class attribute) if the whole-volume call allocates beyond its input and output, in volumes-worth of the case (Gradient5.0;Resample6.5 as a class, plus what the case's own field costs throughcase_working_multiple(name), which is the override for a cost only the case settles): the plan sizes the whole-volume fallback with it. Declaresingle_process = Trueif the stage cannot run inside a daemonic DataLoader worker (KonfAIInferencespawns its nested run): a bound stage that declares it turns the loader's workers off. Declarepatch_locality()(aLocalityKind:POINTWISE/HALO/ORIENTATION/CROP/GLOBAL_STAT/REGRID/SLAB/WHOLE_VOLUME) or the base default makes itWHOLE_VOLUME; aWHOLE_VOLUMEthat is a property of the configuration rather than of the stage must carryreason=, which the plan prints; without it the reader has nothing to change. A per-voxel stage that reads a companion volume (a mask) staysPOINTWISEand overridesstream_region(name, tensor, context, attribute): both dispatchers (the reader's patches and regions, the writer's slabs) hand it where its region sits; it also overridesplan_region_reads(name, contexts), which a sweep calls once before its first region with the contextsstream_regionwill be handed, in order, so the stage declares its companion's windows to the dataset holding it (Dataset.plan_region_reads) and the chunk cache evicts them by next use (a hint: values never depend on it; the patch route, in the loader's order, never calls it);SLAB+stream_slabis the write-side-only contract for a side effect needing the output's slabs in order (InferenceStack), and the reader treats it asWHOLE_VOLUME. AGLOBAL_STATstage declaresstat_keys(a subset ofMin/Max/Mean/Std, or theirPerChannelforms),records(which of them its whole-volume call leaves in the scope under those names;Statisticsnone, aClipbound only when saved) andtakes_present(it takes a statistic already there over measuring its own, asNormalizeandStandardizedo): the streamed route seeds each stage right before it runs, from the store or from a whole-volume pass, and takes back what that route would not have left, so the case attribute, the backup a whole-volume load starts from and a Save header never carry a seed the stage did not record. AREGRIDstage owns both halves:stream_region_source()(the pull map) andstream_region(). Pairinverse()ifapply_inverse; overrideprepare(konfai_args)only when the stage builds a sub-object from configuration of its own (Reduce→ its operator). - Augmentation: subclass
data.augmentation.DataAugmentation;_state_init(sample params per case index) +_compute(apply lazily; it returns a fresh tensor or a view and never writes into the tensor it is handed: the copies alias the case). OnlyPlacedMask,Permuteand a quarter-turnRotatemay change shape; a draw that reorders axes returns its index remap from_axis_remap, so anExpandcopy's header (Spacing, Direction, Origin) follows the axes as thePermutetransform's does. Declare_patch_localitylike a transform so the copies stream (Rotatequarter turns areORIENTATION, free angles andScaleREGRIDthrough the affine's own pull box,Noise/CutOUTPOINTWISEbecause their field and box are functions of the voxel's position); a draw that depends on the place overrides_stream_region(name, index, a, tensor, context). A copy's draws are keyed by the case NAME (_drawn_from(seed, name, kind, occurrence)), never by its index: anExpandcopy's and a PREDICTION TTA copy's (DataAugmentationsList.draw_seed, frommanual_seed); TRAIN's copies redraw every epoch from the seeded global RNG. A draw is also a chain stage:TransformLoaderresolves a bare name againstdata.transformfirst anddata.augmentationsecond, and reverses that order past anExpandmarker (prefer_augmentation), where the chain is the copies' draws: soFlip,PermuteandForeign, which both packages define, are the transform before the marker and the draw after it (the module-qualified spelling forces either). That is how atransforms:block interleaves draws and transforms. - Reduction: subclass
data.reduction.Reduction; implement__call__(list[Tensor]) -> Tensorover the[1, K, C, *spatial]layout both engines hand over. Two consumers, one vocabulary: the predictor folds one case's copies (ensemble/TTA),data.transform.Reducefolds N cases into one. Declarevoxel_local = Trueonly if every output voxel reads the same voxel of each input (a wrongTruecorrupts a streamed output: the gate checks nothing else),incremental = Trueifaccumulatecan fold one at a time,working_multiple= the buffers-worth the operator allocates ON TOP of what it is handed (the plan multiplies it into the peak it sizes regions against). An operator whose route depends on the COUNT overridesworking_multiple_for(cases)and the attribute stays its worst case:Medianselects the middle by a network of element-wise min/max up to five members (measured 1.5 at two, 1.0 at three, 2.5 at four, 1.5 at five) and sorts the stack past that (4.0), and overrideoutput_channels(channels, cases)when the fold changes the channel count (Concatdoes).Reducerefuses a non-voxel_localoperator outright. - Imaging format: add a
Dataset.AbstractFilebackend, register its format token inBACKENDS(utils/dataset/backend.py), register aliases inSUPPORTED_EXTENSIONS, or inSUPPORTED_BACKEND_FORMATSwhen the token is not a suffix any file carries (:itktransformwrites<group>.h5), since only the extensions are probed on disk; import-guard the heavy lib and raise aDatasetManagerErrornaming the extra rather than degrading in silence. Declareread_errors, what the library raises on an entry it cannot open or decode:Datasetturns exactly those into aCaseReadErrornaming the case, and PREDICTION and EVALUATION set that case aside where anything else stops them. Overrideread_granularity(name)when serving a window costs more than the window: a chunked backend decodes whole blocks (zarr, HDF5) and answers its chunk, and a memory-mapped one is served band by band (SitkFileanswers(1, 1, Y, X): one step along the outermost axis a window spans, every axis below it whole, because those are the pages the read touches and the kernel counts). A sweep is priced and cut on that grid, so the grain need not be isotropic and need not come from a compressor; a backend that stays silent is priced at what its reads ask for, which is right only when a read costs exactly that. A compressed ITK file (.nii.gz, a zlib MetaImage, a gzip NRRD) is read by region from an uncompressed twin (utils/dataset/decompressed.py), decompressed once per run under a file lock intoKONFAI_DECOMPRESSED_DIRECTORY(default~/.cache/konfai/decompressed): itsbounded_region_readsis True and itsread_granularitythe twin's band before the twin exists. A region that is the whole volume makes no twin (it decodes once either way). A twin is filed under the entry it is read for (the nameDatasetresolves: the case, or anExpandcopy's entry however deep its pattern), soDatasetManager.release_case(), which a new one-pass reader calls as it leaves a case, removes the twins of that case and of its copies whichever root or stage read them (aMaskgroup, aResamplefield, aSavecache of the copies);run_scope(entered byexecute_distributed_objectand by each rank, which a cluster task reaches with no launcher around it) removes the run's twins however it ends.
Classpaths: a bare name (e.g. Dice) resolves inside that kind's package; module:Class imports any module: a local file (Loss:MyWrapper) or an installed library (monai.losses:DiceLoss, torch:nn:L1Loss). Model classpaths resolve against konfai.models.python. Use the relative or default| form.
YAML model builder (utils/model_builder.py): builds a Network from a .yml, safe by construction (node types must come from two curated registries, no eval/import injection). The shipped catalog (models/yaml/, 14 models incl. UNet/NestedUNet/ResNet/UNETR/ViT/VNet) now covers the feed-forward subset; custom-forward models (DiffusionGAN/ConvNeXt) stay Python. default|<Name>.yml addresses the flat catalog only; a name with a path separator is refused.
A separate package layered on KonfAI's public API (core never imports it). An "app" bundles a config + custom .py + .pt weights, resolved from a Local dir, a HuggingFace repo, or a Remote server; the apps/* bundles are thin CLI wrappers.
⚠️ Trust model. Resolving an app copies and imports its.pyfiles → it runs arbitrary code. It also pip-installs itsrequirements.txtby default (only missing/mismatched packages; core packages liketorch/konfaiare never touched; opt out withKONFAI_APPS_INSTALL_REQUIREMENTS=0). Only resolve apps from sources you trust.
A third package exposing a FastMCP server. It depends on KonfAI's public API and, for validation, on two core internals: konfai_mcp.runner imports konfai.trainer/konfai.network.network builders and patches the single-process runtime (_apply_single_process_patches), which is why the sibling pins are exact at a release tag (a range only from a working tree). It exposes the server so an LLM agent can inspect a dataset → author YAML → run train/predict/evaluate → monitor jobs → compare runs → iterate. On main since v1.6.0 and published to PyPI by the release workflow. Jobs run in a spawn subprocess (training may init CUDA); check_config(level='instantiate') and check_component run in a spawn subprocess (never in the server process), are side-effect-free (the child reads a scratch copy placed beside the config, so the original is never written, an edit made meanwhile survives, and the parent sweeps a copy a killed child left), and re-import edited workspace code; discovery is via list_components / describe_extension_points / describe_config_schema / check_component. Tests: pip install -e ./konfai-mcp then python -m pytest konfai-mcp/tests (the segmentation E2E needs the imaging extra).
Working on the MCP server, how to validate a change:
- Synthetic fixtures:
pixi run --environment dev python konfai-mcp/tests/make_fixtures.pybuilds a segmentation dataset, a registration pair with a known translation, a synthesis pair, a 3-level OME-Zarr store, and corrupted/unsupported inputs under the gitignoredkonfai-mcp/tests/fixtures/(procedural, no patient data). Reuse these, do not invent ad-hoc data in/tmp. - Drive it black-box first, not by tool name. Formulate a real objective ("segment these CT volumes"),
then exercise the loop through a
fastmcp.Clientexactly astest_mcp_server_segmentation_pipeline.pydoes. A new tool is not "done" because it returns without an exception. - Verify outputs, not return codes. After
run_*+get_job_status(wait=True), assert the jobstatus=="done"(never trust a greenvalidate_*alone: its default levelinstantiateruns no train step; onlylevel='train_step'runs one forward+backward), then open the produced files:read_filethe config, checkPredictions/<name>/Datasetexists, and read theMetric_*.jsonviaget_run_metrics. Confirm the numbers correspond to the requested task. - Validating a new tool: (1) its
next_actionsmust be registered tool names; the anti-drift test intest_mcp_server_tool_index.pyenforces this for job payloads only, so if the tool emitsnext_actionsin its own payload, assert it in the tool's pytest; (2) if it takes a workspace path, route it throughresolve_workspace_relative_path(jail); (3) if it imports/executes app or workspace code, run it in the spawn subprocess (run_api_in_subprocess) and gate it behindallow_untrusted_codewhere applicable; (4) document per-parameter meaning viaAnnotated[..., Field(description=...)], not only prose; (5) add a pytest that inspects the output. - Adding a workflow kind is one
WorkflowSpecentry inkonfai-mcp/konfai_mcp/workflows.pyplus the twoLiteralaliases beside it (and aGUIDE/tool description); every other map (config filename, root key, runner command, capabilities class, retry tool) derives from that table, andtests/test_workflow_registry.pypins the derivations to it. - Safety invariants to preserve: validation/smoke-tests never execute in the server process;
read/write_session_fileand theoutputofrun_appandfine_tune_appare path-jailed to the session;fetch_app(path=...)(path) andpackage_app_from_session(output) write to a host folder the caller names, by design, andfetch_app(path=...)refuses to replace a file already there unlessoverwrite=True(dataset tools read arbitrary host paths by design, so keep it that way only for the trusted-local deployment, and never widen writes).cancel_jobnow reaps the whole process group: the job runsos.setsid()and cancel sends the signal viaos.killpg, somp.spawnDDP grandchildren are killed with the middle process (regression test:test_cancel_reaps_the_whole_process_group_including_grandchildren). - Regenerate derived docs: after changing a tool's description run
python konfai-mcp/scripts/generate_tool_reference.py(the committed skill reference is generated). - KonfAI Scout and FidOpt (
konfai_mcp/server_scout.py):run_scout_inspectionruns a visual inspection (the separatekonfai-scoutpackage) as a tracked job andread_scout_reportread it back without images; images reach a cloud model only withallow_cloud_images. Thefidopt_*tools reach FidOpt through its own CLI and Python (KONFAI_MCP_FIDOPT), launch onlyKONFAI_MCP_FIDOPT_ALLOWEDoperations, and reimplement nothing of it. Studio's Scout tab reads the same reports (/api/scout/*).
A fifth package, konfai_scout: an agent that looks at ONE result the way a person would at a viewer (coverage
montages, then the slices, zooms and overlays it asks for), measures regions through KonfAI Eval, and writes a
versioned report of localised defects (measured property / suspected error / confirmed error, severity,
confidence, the views and measurements cited). It depends on konfai only and reuses it everywhere it can:
konfai.data.geometry for grids and boxes, konfai.utils.dataset for the staged layout (entry_backend,
link_entry, the same as konfai-apps) and region reads through every backend, konfai.transform with Resample
and TransformPoints for a registration's warped data, konfai.evaluate for every metric,
konfai.utils.ITK.field_jacobian_determinant for the Jacobian view. Scout adds the viewer (views.py,
planes.py), the tools and prompts the model drives, the loop, the report and the providers (Claude through the API
or through Claude Code's Agent SDK, GPT through the API or through the Codex CLI, any OpenAI-compatible local
server, a mock for tests). Images leave the machine only under allow_cloud_images. note enforces the epistemic
rules mechanically (an error cites a view that shows its box, a figure comes from a cited measurement, a
measurement covers the box; an empty measurement confirms nothing), an optional blind second reading re-reads
every error from crops alone, and synthetic.py builds clean cases (negative controls) beside the planted ones so
invented defects are counted (examples/study_controls.py).
konfai-mcp exposes it (run_scout_inspection, §5b) and Studio shows its reports. konfai-scout/README.md and
konfai-scout/docs/ hold the user side, the report schema and the studies.
pixi run check # lint + format-check + core tests + apps tests (run ONCE before finalising); the test tasks pin OMP_NUM_THREADS=1 (8x faster on 24 cores)
pixi run test # core unit + integration (tests/), ~3 min (pytest-xdist), not an iteration loop
pixi run test-fast # dev loop (~25 s): skips slow oracle + integration tests
pixi run --environment dev typecheck # mypy konfai
pixi run --environment dev python -m pip install -e "./konfai-apps[server]" && pixi run --environment dev python -m pytest konfai-apps/tests # apps suite (separate; [server] for the job-server tests)
pixi run --environment dev python -m pip install -e ./konfai-mcp && pixi run --environment dev python -m pytest konfai-mcp/tests # mcp suite (separate; the pin is a range from a working tree, exact at a tag)
pixi run --environment dev python -m pip install -e ./konfai-studio && pixi run --environment dev python -m pytest konfai-studio/tests # studio suite (separate; build the front first or its two front tests skip)
pixi run --environment dev python -m pip install -e "./konfai-scout[dev]" && pixi run --environment dev python -m pytest konfai-scout/tests # scout suite (separate; no API key: fake clients and a mock model)The Pixi dev env and a bare pip install .[dev] carry the same dependency list, imaging extras included (the dev extra IS the dev environment). pixi run test does not run the sibling suites (konfai-apps/, konfai-mcp/, konfai-studio/, konfai-scout/); install those packages first (they pull their own runtime deps), exactly as their CI does. Studio's suite also needs its React front on disk (cd konfai-studio/frontend && npm ci && npm run build, emitting konfai_studio/web/): without it the two tests of the served front skip and the run still reports green. Install runtime extras with pip install konfai[<extra>] (itk, hdf5, dicom, omezarr, imaging, tensorboard, lpips, cluster, export, check, …).
Versions are tag-derived (setuptools_scm, ^v(?P<version>.*)$) for every package, so never hand-edit
a version. Pushing a v* tag runs .github/workflows/publish.yml: test (core + apps + mcp + studio) → build
the 10-package matrix (konfai-apps, konfai-mcp, konfai-studio, konfai-scout, and the 5 apps/* bundles) → publish to
PyPI via OIDC → build the Docker image once konfai is visible on PyPI. apps/* bundles pin konfai== and
konfai-apps==, and konfai-studio pins konfai-mcp== (via its setup.py), all the same version, so the
whole matrix releases in lockstep. konfai-studio is the one exception to the pure-Python build: the build
job runs npm ci && npm run build first (its React front is git-ignored) and then python -m build --wheel
(wheel-only, because the sdist file-finder would drop the built web/). konfai-mcp is wheel-only too: its
setup.py copies the repository's examples/ into the wheel, and an sdist holds none to copy. A break may ship in
any release, with a refusal naming the new spelling and no deprecation period (docs/source/development.md).
Before tagging: pixi run check green; both sibling suites green; the HF bundles tagged vX.Y.Z too (§7b); pixi run perf-check within its thresholds on a quiet machine (or the regression named in the release notes; the facts of the series, voxel identity and exact differences, also gate every pull request on a GitHub runner, see benchmarks/perf/README.md); and, because the test job only exercises
the source tree, confirm the built wheel still ships konfai/models/python/** and konfai/models/yaml/*.yml
by installing it non-editable in a clean venv (an editable install hides PEP 420 / package-data breakage).
The changelog is written before the tag, not after. CHANGELOG.md is generated from the commits and the
publish workflow renders the same section into the GitHub Release, so the file must already contain the version
being tagged:
uvx --from commitizen cz changelog --unreleased-version vX.Y.Z --start-rev v1.5.8 # 1. draft the section
git commit -am "ci: changelog for vX.Y.Z" # 2. commit it
git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z # 3. sign, tag, pushThe draft is not the section: it sees commit subjects only (a squash merge is one line, a subject with no
conventional prefix is dropped), and the command regenerates the whole file, edited sections included. Keep the
new section, restore the rest from git, then write what a user gets (docs/source/development.md, "Cutting a
release").
-s and not -a: releases are signed (v1.7.0 is), and tag.gpgsign already makes -a sign on a machine
that has it configured, which is exactly why -s is spelled out. On a machine that does not, -a publishes an
unsigned release in silence where -s refuses. Commitizen's own gpg_sign is deliberately NOT set: it would
only apply to cz bump, which does not tag here, so it would be a setting that describes an intention nothing
acts on.
cz bump computes the right increment (cz bump --dry-run is a good second opinion on major/minor/patch) but
does not tag here: with version_provider = "scm" there is no version file to write, so when the changelog
is already current it has nothing to commit and stops silently. Tag by hand. --start-rev v1.5.8 is required:
Conventional Commits only took hold at v1.5.9, and rendering further back emits empty version headings.
- Never load a full volume into RAM on a route that can stream. Use lazy/patch/streaming access (
can_stream_patch,read_data_slice). TRANSFORM'sWHOLE-VOLUMEverdict, the loaders' statistic pass and PREDICTION's budget-fitting TTA case (§1) are the exceptions, each declared where it runs, never a read a stage takes on its own. - Channel-first
[C,(Z),Y,X]; spacing(x,y,z).Attributestringifies every value and reparses geometry vianp.fromstring, so only flat scalars / 1-D arrays round-trip. Read via__getitem__/get_np_array. KONFAI_config_file+KONFAI_CONFIG_MODEmust be set before anyConfig()(tests mustmonkeypatch.setenvboth); workflows requireKONFAI_CONFIG_MODE='Done'. Reading a config rewrites it on disk once the build succeeds; a failed build leaves it as it was (--initexcepted).- Patch ordering must match between read (
disassemble) and write (Accumulator); for PREDICTION/EVALUATION all patches of a case stay on the same DDP rank. outputs_criterionskeys equal a module's dotted path; the:/.separators are load-bearing.state_dictload/save does not recurse into nestedNetworks (each owns its optimizer/state); alias lists are positional.- The YAML model builder is the trusted/untrusted boundary: only registry types, and module names contain no
.. konfai-appsis a separate package;apps/is excluded from thekonfaiwheel. Core never importskonfai_apps: a chain names its stagekonfai_apps.transforms:KonfAIInference.- The pretrained bridge fills every target tensor or raises; never report a partial load as success.
- The config write is atomic (temp +
os.replace); a reader must never see a truncated config and bind all-defaults.
- SlicerKonfAI (separate repo) drives
konfai-appsby CLI + JSON. It imports, fromkonfai_apps.app_repository:current_free_vram(devices, remote_server=None),get_app_repository_info,is_app_repo,LocalAppRepositoryFromDirectory,LocalAppRepositoryFromHF,AppRepositoryError, plus theget_parameters() -> {values, constraints}params primitive that drives its advanced-params dialog. Renaming or dropping any of these breaks Slicer silently (it happened once: PR#33 droppedcurrent_free_vram, PR#35 restored it). Grep the Slicer checkout before touching that surface. SlicerKonfAI and SlicerImpactReg also import nine core symbols:konfai.evaluator.Statistics,konfai.utils.dataset.image_to_dataandget_infos,konfai.utils.runtime.MinimalLog, and fromkonfaiitselfget_available_devices,get_ram,get_vram,check_server,assert_konfai_install, pinned bytests/unit/test_slicer_core_api_contract.py.konfai-apps/tests/test_slicer_api_contract.pypins thekonfai_appsnames, the methods Slicer calls on an app repository with the arguments it passes, and the attributes it reads. A symbol whose only caller here is such a test is still in use. - HF bundles (
hf_bundles/*) carryapp.json+ config +requirements.txt+.pt+ custom.py. Their configs use bundle-relative classpaths (ResidualEncoderUNet.yml,model:Unet_TS_CT,Model:RegistrationNet), never KonfAI's internal module paths, which is why the models→models.pythonmove did not break them. Validate a bundle by loading its config on a copy (reading mutates). An unpinned reference resolves to the tagv<konfai-apps version>when the repository has it, elsemain: tag every bundle repositoryvX.Y.Zat each release, once its configs are final (git tag vX.Y.Z && git push origin vX.Y.Zin eachhf_bundles/*clone).
Three, and only three, places decide trust. Keep them honest:
- YAML model builder: registry types only; the
default|catalog name must stay a bare filename. konfai-appsapp resolution: runs the app's.pyand pip-installs itsrequirements.txtby default (KONFAI_APPS_INSTALL_REQUIREMENTS=0opts out). Protected core packages are matched by PEP 503 canonical name (konfai_apps≡konfai-apps); transitive deps are not policed, so say so and don't overclaim.konfai-mcp: validation/smoke-tests run only in a spawn subprocess (never the server process);read/write_session_fileand theoutputofrun_appandfine_tune_appare path-jailed; dataset tools may read arbitrary host paths by design, but writes must never widen (any tool that composes a write target must reject path separators). Two tools take a free host destination, by design:fetch_app(path=...)(path, which refuses to replace an existing file withoutoverwrite=True) andpackage_app_from_session(output).cancel_jobreaps the whole process group. Over SSE/HTTP with no bearer token, the CLI refuses a non-loopback bind (--i-know-this-is-insecureoverrides) and the server answers only loopbackHostnames (DNS rebinding).
- An editable install hides packaging breakage (
models/pythonis PEP 420; catalog.ymlis package-data). - A green
validate_*proves little: its default levelinstantiateruns no train step; onlylevel='train_step'does a real forward+backward. transform_shape()must be exact: patch planning trusts it, a wrong prediction corrupts reassembly.- Reading a config mutates it, so build from a scratch copy beside it (what konfai-mcp's validation does), never from the author's file.
- The mirrors-mypy pre-commit hook cannot type-check this code: it runs in an environment of its own, without torch, and reads every torch attribute as
Any | None(199 errors on a tree mypy passes). The hook is alocalone running the dev environment's mypy, the same command aspixi run typecheckand the CI job; keep it that way. - Adding a workflow kind: in konfai-mcp this is one
WorkflowSpecentry + twoLiteralaliases (drift-tested, see §5b); in core it still touches several maps (main.py_COMMANDS/_INIT_TARGETS,State, api.py). Prefer extending the descriptor tables over scattering new registries. - Union coercion in the config binder is declaration-order-sensitive:
overlap: 0.25once bound0(lossyintwon). Fixed; pinned bytest_config.py::test_apply_config_union_keeps_the_value_type_over_lossy_coercion. Any new union-typed config key still needs a test. - A model YAML named by a relative path resolves next to the config file that names it
(
ModelLoader._yaml_path). The Python API copies a config file to scratch before reading it (the write-back must not land on the caller's file), so_config_copymakes such a classpath absolute first; a new entry point that materializes a config elsewhere must do the same. Pinned bytest_api.py::test_a_relative_model_yaml_is_anchored_to_the_config_files_directory. - A
Prediction.ymlthat preprocesses a group differently from theConfig.ymlit trained on is silent: the run succeeds and only the output is wrong. The Synthesis example shipped withStandardize(mask: None)in training againstStandardize(mask: MASK)plus an extra inputMaskin prediction: same checkpoint, 409 HU of MAE instead of 98. Nothing checks it. The tell is an evaluated metric that contradicts the training loss by an order of magnitude (training MAE 0.018 in[-1, 1]≈ 36 HU); diff the two transform stacks before blaming the model or the epochs. - The sibling packages pin
konfai==<their own scm version>, which from a working tree is a.devversion no installed core carries.setup.py:_siblingpins the closest release or newer from a tree and the exact version at a tag; keep that helper when adding a package. Pinned bytest_packaging.py::test_sibling_pins_resolve_against_the_core_of_this_tree.
Fixed, pinned by a test (do not re-fix; the guide once listed them as open):
- The one-pass case FIFO: prediction and evaluation hold the case being finished and the next one,
whatever the batch size (the training-sized
batch_size + 1kept whole cases loaded, a case per case of RAM growth on a cohort) (test_data_manager.py::test_a_one_pass_source_holds_two_cases_whatever_its_batch_size). - Nested-
Networkoptimizer/scheduler state on RESUME:checkpoint_saveandNetwork.loadboth use the dottedget_networks()key (test_network.py::test_load_restores_nested_network_optimizer_and_counters). - Inline augmentation redraws under persistent DataLoader workers: the loader forces
persistent_workers=Falsewhen inline augmentation is active (test_data_manager.py::test_inline_augmentations_disable_persistent_workers). - The train/val split seed: drawn from
manual_seed, else theSeed.txtthe TRAIN run recorded on RESUME, else a fresh draw recorded for the next RESUME (test_trainer.py::test_resume_reuses_the_recorded_split_seed).
- Code: line length 120 (Ruff); type annotations on new public functions; Apache-2.0 SPDX header on every new source file; prefer
pathlib.Path; use the error classes inutils/errors.py(do not invent exceptions); import-guard heavy optional deps (SimpleITK/h5py/pydicom/zarr), failing at point-of-use with an install hint, not at import. - Commits: Conventional Commits (
cz check):type(scope): subject, imperative, < 72 chars. No AI-agent branding (claude/codex/"generated by/with") and no AI co-author trailers. - For agents: read before editing; keep diffs small (one logical change per PR, no unrelated reformats); iterate cheaply, verify once: while developing run only the per-module test file you touched (
pytest tests/unit/test_<module>.py), thenpixi run test-fast(~25 s); reserve the fullpixi run check(and the apps suite if you touchedkonfai-apps) for one final pass before finalising, because the full suite takes ~3 min and re-running it per edit adds up fast; no new runtime dependency without an explicit request + a matchingpyproject.tomlupdate in the same commit; update docs andtests/unit/test_config.pywhen changing config binding; do not skip pre-commit with--no-verify.
Prefer efficient code retrieval over broad file reads.
- Use CocoIndex Code for conceptual or semantic code search.
- Use Serena for symbols, references, declarations, and symbolic refactoring.
- Prefer targeted reads over entire files.
- Do not repeatedly search or reread code already understood.