seqforge 2026.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """seqforge — compile FASTQ + metadata into a validated library manifest and a Snakemake config.
2
+
3
+ A compiler, not a chatbot: deterministic code owns every decision; the LLM only parses prose into
4
+ span-verified assertions and arbitrates already-flagged ambiguity. See ``docs/design.md``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ try: # pragma: no cover - version is provided by the installed package metadata
10
+ from importlib.metadata import version
11
+
12
+ __version__ = version("seqforge")
13
+ except Exception: # pragma: no cover - not installed; mirror the static CalVer in pyproject.toml
14
+ __version__ = "2026.7.1"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,38 @@
1
+ """The ``seqforge`` Typer application, assembled from one module per command group.
2
+
3
+ The CLI is the API: every skill action maps to a deterministic ``seqforge <verb>`` (JSON on stdout by
4
+ default) that runs with no LLM in the loop -- only ``harvest extract`` and the opt-in
5
+ ``resolve adjudicate`` touch an LLM. Exit codes are uniform: ``0`` OK, ``1`` ERROR, ``2`` USAGE,
6
+ ``3`` BLOCKED (a Blocker), ``4`` NEEDS_HUMAN (an open Conflict / question).
7
+
8
+ Importing this package builds ``app``: :mod:`.root` defines the shared Typer instances, and importing
9
+ each command module registers its verbs onto them. A handful of internals are re-exported because the
10
+ test suite and ``seqforge run`` reach for them by name.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Importing each command module runs its @command decorators, registering the verbs onto `app`.
16
+ # The imports look unused; the registration is the side effect that assembles the CLI.
17
+ from . import ( # noqa: F401
18
+ compose,
19
+ eval,
20
+ harvest,
21
+ hook,
22
+ io,
23
+ kb,
24
+ manifest,
25
+ probe,
26
+ processing,
27
+ project,
28
+ resolve,
29
+ run,
30
+ schema,
31
+ )
32
+ from ._common import _emit, _StageOut # noqa: F401
33
+ from .harvest import _harvest_extract_pipeline # noqa: F401
34
+ from .manifest import _fill_manifest_pipeline # noqa: F401
35
+ from .root import app
36
+ from .run import _harvest_halts_run # noqa: F401
37
+
38
+ __all__ = ["app"]
@@ -0,0 +1,8 @@
1
+ """``python -m seqforge.cli`` -- the entry point the generated hook shim invokes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import app
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ app()
@@ -0,0 +1,105 @@
1
+ """Shared CLI helpers used across command groups: stage results, safe loaders, small parsers.
2
+
3
+ None of these touch a Typer app -- they are the plumbing every command group reuses. `_StageOut`
4
+ decouples *what a stage says and whether it refused* (the exit code) from *where that output goes*,
5
+ which is what lets one stage body serve both a standalone verb and the one-pass `run`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ import yaml
16
+ from pydantic import ValidationError
17
+
18
+ from ..io.taxonomy import resolve as resolve_organism
19
+ from ..models.dataset import DatasetManifest
20
+ from ..models.processing import ProcessingManifest
21
+
22
+
23
+ def _today() -> str:
24
+ """Today, for the ``fetched`` stamp on a generated vocabulary file.
25
+
26
+ Local import and a function rather than a module constant: a constant would be evaluated at import
27
+ time, and every artifact seqforge writes is content-addressed — a clock reachable from module
28
+ scope is a clock that eventually ends up inside a hash.
29
+ """
30
+ import datetime
31
+
32
+ return datetime.date.today().isoformat()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _StageOut:
37
+ """One stage's result, decoupled from how it is printed.
38
+
39
+ A stage decides *what* to say and *whether it is a refusal* (the exit code); the command wrapper
40
+ decides *where* it goes. That split is what lets a single stage body serve both a standalone verb
41
+ (which echoes it and exits) and ``seqforge run`` (which folds it into one summary). ``payload`` is
42
+ a dict rendered as JSON, or a bare string echoed as-is — ``FillError`` prints a plain sentence,
43
+ ``records_unavailable`` prints JSON, and both must keep doing exactly that.
44
+ """
45
+
46
+ payload: dict[str, object] | str
47
+ code: int
48
+ err: bool = False
49
+
50
+
51
+ def _emit(out: _StageOut) -> None:
52
+ """Print a stage result the way a standalone verb does, then exit with its code."""
53
+ body = out.payload if isinstance(out.payload, str) else json.dumps(out.payload, indent=2)
54
+ typer.echo(body, err=out.err)
55
+ raise typer.Exit(out.code)
56
+
57
+
58
+ def _auto_cpus(cpus: int) -> int:
59
+ """Resolve ``--cpus``: a positive value is taken as-is; ``0`` means auto = ``min(8, detected)``.
60
+
61
+ Files probe in parallel across processes, and cores are not a budget — this only decides how
62
+ fast, never what. ``0`` is the default so the common multicore case is fast without a flag, while a
63
+ shared login node can be pinned with ``--cpus 1``. The cap at 8 keeps a 96-core node from
64
+ fork-bombing itself on a 12-file dataset where the win is already gone by 8.
65
+ """
66
+ if cpus > 0:
67
+ return cpus
68
+ import os
69
+
70
+ return max(1, min(8, os.cpu_count() or 1))
71
+
72
+
73
+ def _load_manifest(path: Path) -> DatasetManifest:
74
+ try:
75
+ return DatasetManifest.model_validate(yaml.safe_load(path.read_text()))
76
+ except (OSError, ValidationError, ValueError) as exc:
77
+ typer.echo(f"cannot read manifest {path}: {exc}", err=True)
78
+ raise typer.Exit(2) from exc
79
+
80
+
81
+ def _load_processing(path: Path) -> ProcessingManifest:
82
+ try:
83
+ return ProcessingManifest.model_validate(yaml.safe_load(path.read_text()))
84
+ except (OSError, ValidationError, ValueError) as exc:
85
+ typer.echo(f"cannot read processing manifest {path}: {exc}", err=True)
86
+ raise typer.Exit(2) from exc
87
+
88
+
89
+ def _resolve_organism(value: str, *, offline: bool = False) -> int:
90
+ """`--organism` takes a taxid or a name. A bare integer is taken at face value.
91
+
92
+ Not "is it all digits, else look it up" with a fallback -- a name that happens to be numeric is
93
+ not a thing, and a taxid that fails to parse should say so rather than be searched for on NCBI.
94
+ """
95
+ text = value.strip()
96
+ if text.isdigit():
97
+ return int(text)
98
+ return resolve_organism(text, offline=offline)
99
+
100
+
101
+ def _parse_quantify(value: str | None) -> tuple[str, ...] | None:
102
+ """`--quantify Gene,GeneFull` -> the tuple. The MODEL validates membership, not this parser."""
103
+ if value is None:
104
+ return None
105
+ return tuple(v.strip() for v in value.split(",") if v.strip())
@@ -0,0 +1,119 @@
1
+ """`seqforge compose` -- compile (dataset, processing) -> Snakefile + config.yaml + units.tsv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from .. import __version__
11
+ from ..compose import ComposeError, compose
12
+ from ..io import default_registry
13
+ from ..kb import load_spec
14
+ from ..manifest import (
15
+ ProcessingInputs,
16
+ exit_code_for_report,
17
+ fill_processing,
18
+ validate_manifest,
19
+ validate_processing,
20
+ )
21
+ from ._common import _load_manifest, _load_processing
22
+ from .root import app
23
+
24
+
25
+ @app.command("compose")
26
+ def compose_cmd(
27
+ manifest_path: Path = typer.Argument(..., help="Path to a validated manifest.yaml."),
28
+ processing_path: Path | None = typer.Option(
29
+ None, "--processing", help="A processing manifest. Omit to use policy defaults."
30
+ ),
31
+ assembly: str | None = typer.Option(
32
+ None, "--assembly", help="Genome, when composing without --processing."
33
+ ),
34
+ annotation: str | None = typer.Option(
35
+ None, "--annotation", help="Registered GTF name, when composing without --processing."
36
+ ),
37
+ workspace: Path = typer.Option(
38
+ Path("."), "-C", "--workspace", help="Root for seqforge/ state."
39
+ ),
40
+ outdir: str = typer.Option(
41
+ "results", help="Pipeline output directory (written into the config)."
42
+ ),
43
+ fastq_dir: Path | None = typer.Option(
44
+ None,
45
+ "--fastq-dir",
46
+ help="Where this machine keeps the FASTQs. Without it units.tsv carries bare basenames "
47
+ "and the pipeline cannot find its input.",
48
+ ),
49
+ onlist_dir: Path | None = typer.Option(
50
+ None,
51
+ "--onlist-dir",
52
+ help="Directory of downloaded barcode whitelists (<name>.txt.gz). Checked before the "
53
+ "network, so a compute node with no internet still composes. Env: SEQFORGE_ONLIST_DIR.",
54
+ ),
55
+ sif_dir: Path | None = typer.Option(
56
+ None,
57
+ "--sif-dir",
58
+ envvar="LIU_LAB_PACKAGES",
59
+ help="Directory of prebuilt liulab-runtime images (liulab-runtime_<env>.sif). Used instead "
60
+ "of the ghcr tag when the file is there, for nodes that cannot reach ghcr.io.",
61
+ ),
62
+ ) -> None:
63
+ """Compile (dataset, processing) -> Snakefile + config.yaml + units.tsv.
64
+
65
+ ``--processing`` is optional: a processing manifest exists because someone wanted something
66
+ non-default, and requiring one per dataset would mean 10^4 boilerplate files nobody reads. Either
67
+ way compose writes the fully-resolved, dataset-bound manifest it used to processing.lock.yaml, so
68
+ the run's state is on disk regardless. Exit 3 if a gate fails.
69
+ """
70
+ manifest = _load_manifest(manifest_path)
71
+ report = validate_manifest(manifest)
72
+ if not report.ok:
73
+ typer.echo(json.dumps(report.model_dump(mode="json"), indent=2), err=True)
74
+ typer.echo("refusing to compose an invalid manifest", err=True)
75
+ raise typer.Exit(exit_code_for_report(report))
76
+
77
+ if processing_path is not None:
78
+ processing = _load_processing(processing_path)
79
+ else:
80
+ if assembly is None or annotation is None:
81
+ # The one thing with no safe default. Deriving an assembly from experiment.organism would
82
+ # mean choosing hg38 vs hg19 vs T2T on the user's behalf — a policy call, and that map is
83
+ # liulab-genome's job. Refuse, but make the refusal actionable.
84
+ typer.echo(
85
+ f"compose needs a genome: this dataset's organism is taxid "
86
+ f"{manifest.experiment.organism.value}. Pass --assembly/--annotation, or author one "
87
+ f"with `seqforge processing new`.",
88
+ err=True,
89
+ )
90
+ raise typer.Exit(2)
91
+ processing, _ = fill_processing(
92
+ spec=load_spec(manifest.library.chemistry.value[0]),
93
+ dataset=manifest,
94
+ processing=ProcessingInputs(assembly=assembly, annotation_name=annotation),
95
+ seqforge_version=__version__,
96
+ )
97
+
98
+ p_report = validate_processing(processing, dataset=manifest)
99
+ if not p_report.ok:
100
+ typer.echo(json.dumps(p_report.model_dump(mode="json"), indent=2), err=True)
101
+ typer.echo("refusing to compose with an invalid processing manifest", err=True)
102
+ raise typer.Exit(exit_code_for_report(p_report))
103
+
104
+ try:
105
+ result = compose(
106
+ manifest,
107
+ processing,
108
+ registry=default_registry(offline=False, local_dir=onlist_dir),
109
+ workspace=workspace,
110
+ outdir=outdir,
111
+ fastq_dir=fastq_dir,
112
+ sif_dir=sif_dir,
113
+ )
114
+ except ComposeError as exc:
115
+ typer.echo(str(exc), err=True)
116
+ raise typer.Exit(3) from exc
117
+ typer.echo(json.dumps(result.model_dump(mode="json"), indent=2))
118
+ if any(v == "fail" for v in result.gate.values()):
119
+ raise typer.Exit(3)
seqforge/cli/eval.py ADDED
@@ -0,0 +1,103 @@
1
+ """`seqforge eval` -- the evals harness: measure what unit tests cannot (design/brief S9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from .root import eval_app
11
+
12
+
13
+ @eval_app.command("list")
14
+ def eval_list(
15
+ cases_dir: Path | None = typer.Option(
16
+ None, "--cases", help="Case root (default: evals/cases)."
17
+ ),
18
+ ) -> None:
19
+ """List the eval corpus: id, expected outcome, and whether the case needs an LLM."""
20
+ from ..evals import CaseError, load_cases
21
+
22
+ try:
23
+ cases = load_cases(cases_dir)
24
+ except CaseError as exc:
25
+ typer.echo(str(exc), err=True)
26
+ raise typer.Exit(2) from exc
27
+ payload = [
28
+ {
29
+ "id": c.id,
30
+ "outcome": c.expected.outcome,
31
+ "needs_llm": c.has_prose and c.recipe.hypothesis is None,
32
+ "description": " ".join(c.expected.description.split())[:100],
33
+ }
34
+ for c in cases
35
+ ]
36
+ typer.echo(json.dumps(payload, indent=2))
37
+
38
+
39
+ @eval_app.command("run")
40
+ def eval_run(
41
+ case: list[str] = typer.Option(None, "--case", help="Run only these case ids (repeatable)."),
42
+ cases_dir: Path | None = typer.Option(
43
+ None, "--cases", help="Case root (default: evals/cases)."
44
+ ),
45
+ llm: bool = typer.Option(
46
+ False, "--llm/--no-llm", help="Run prose cases through harvest extract (costs tokens)."
47
+ ),
48
+ provider: str | None = typer.Option(
49
+ None, "--provider", help="anthropic | deepseek | openai-compatible (default: auto-detect)."
50
+ ),
51
+ model: str | None = typer.Option(
52
+ None, "--model", help="Override the provider's default model."
53
+ ),
54
+ trials: int = typer.Option(
55
+ 1, "--trials", min=1, help="Re-run each prose case N times; extraction is nondeterministic."
56
+ ),
57
+ fail_under: float = typer.Option(
58
+ 1.0, "--fail-under", help="Exit 3 if field accuracy drops below this."
59
+ ),
60
+ ) -> None:
61
+ """Run the eval corpus and report brief §9's metrics.
62
+
63
+ `--no-llm` (the default) restricts to deterministic cases, so this runs in a CI with no API key;
64
+ prose cases skip rather than fail. Exit 3 if any false-accept occurs or accuracy drops below
65
+ `--fail-under` — a false accept is never tolerable at any threshold, so it is not on a slider.
66
+ """
67
+ from ..evals import CaseError, Grade, load_cases, run_cases
68
+ from ..harvest import ProviderUnavailable, resolve_provider
69
+
70
+ try:
71
+ cases = load_cases(cases_dir, only=list(case) if case else None)
72
+ except CaseError as exc:
73
+ typer.echo(str(exc), err=True)
74
+ raise typer.Exit(2) from exc
75
+ if not cases:
76
+ typer.echo("no cases found", err=True)
77
+ raise typer.Exit(2)
78
+
79
+ llm_provider = None
80
+ if llm:
81
+ try:
82
+ llm_provider = resolve_provider(provider)
83
+ except ProviderUnavailable as exc:
84
+ typer.echo(json.dumps({"error": "no_provider", "detail": str(exc)}, indent=2), err=True)
85
+ raise typer.Exit(1) from exc
86
+
87
+ report, runs = run_cases(cases, llm=llm, provider=llm_provider, model=model, trials=trials)
88
+ typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
89
+
90
+ false_accepts = [r for r in runs if r.skipped is None and r.grade.grade is Grade.FALSE_ACCEPT]
91
+ if false_accepts:
92
+ typer.echo(
93
+ f"FALSE ACCEPT in {len(false_accepts)} case(s): "
94
+ f"{[r.case_id for r in false_accepts]} — a confident wrong manifest is the one "
95
+ f"failure the corpus never recovers from",
96
+ err=True,
97
+ )
98
+ raise typer.Exit(3)
99
+ if report.field_accuracy < fail_under:
100
+ typer.echo(
101
+ f"field accuracy {report.field_accuracy:.3f} < --fail-under {fail_under}", err=True
102
+ )
103
+ raise typer.Exit(3)