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.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- 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__"]
|
seqforge/cli/__init__.py
ADDED
|
@@ -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"]
|
seqforge/cli/__main__.py
ADDED
seqforge/cli/_common.py
ADDED
|
@@ -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())
|
seqforge/cli/compose.py
ADDED
|
@@ -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)
|