fieldbench 0.2.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.
fieldbench/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """FieldBench — a cross-domain, field-level benchmark for schema-driven document extraction."""
2
+
3
+ from .aggregate import DocResult, Report, build_report
4
+ from .corpus import list_documents, score_corpus
5
+ from .run import LLMRunner, Runner, run_corpus
6
+ from .scoring import SCORER_VERSION, FieldResult, compare_field
7
+
8
+ __version__ = "0.2.1"
9
+
10
+ __all__ = [
11
+ "compare_field",
12
+ "FieldResult",
13
+ "SCORER_VERSION",
14
+ "score_corpus",
15
+ "list_documents",
16
+ "build_report",
17
+ "DocResult",
18
+ "Report",
19
+ "run_corpus",
20
+ "Runner",
21
+ "LLMRunner",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,120 @@
1
+ """Aggregate per-field results into a FieldBench report.
2
+
3
+ Reports micro/macro field accuracy, always stratified real vs synthetic, with a
4
+ four-way confusion breakdown (correct-absence / hallucination / miss /
5
+ wrong-value) and the all-null degenerate floor — so every score is read against
6
+ what an empty extractor scores for free.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter, defaultdict
12
+ from dataclasses import dataclass, field
13
+
14
+ from .scoring import SCORER_VERSION, FieldResult, is_empty
15
+
16
+
17
+ @dataclass
18
+ class DocResult:
19
+ stem: str
20
+ category: str
21
+ source: str # "real" | "synthetic" | "unknown"
22
+ fields: list[FieldResult]
23
+
24
+
25
+ @dataclass
26
+ class Stratum:
27
+ docs: int = 0
28
+ fields: int = 0
29
+ passed: int = 0
30
+ weighted: float = 0.0
31
+ four_way: Counter = field(default_factory=Counter)
32
+
33
+ @property
34
+ def accuracy(self) -> float:
35
+ return self.passed / self.fields if self.fields else 0.0
36
+
37
+ @property
38
+ def partial(self) -> float:
39
+ return self.weighted / self.fields if self.fields else 0.0
40
+
41
+ def to_dict(self) -> dict:
42
+ n = self.fields or 1
43
+ return {
44
+ "docs": self.docs,
45
+ "fields": self.fields,
46
+ "passed": self.passed,
47
+ "accuracy": round(self.accuracy, 4),
48
+ "partial_credit": round(self.partial, 4),
49
+ "four_way": {
50
+ bucket: {"count": c, "rate": round(c / n, 4)}
51
+ for bucket, c in sorted(self.four_way.items())
52
+ },
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class Report:
58
+ scorer_version: str = SCORER_VERSION
59
+ mode: str = "unspecified" # which representation the predictions came from
60
+ overall: Stratum = field(default_factory=Stratum)
61
+ by_source: dict[str, Stratum] = field(default_factory=dict)
62
+ by_category: dict[str, Stratum] = field(default_factory=dict)
63
+ four_way: Counter = field(default_factory=Counter)
64
+ all_null_floor: float = 0.0
65
+
66
+ def to_dict(self) -> dict:
67
+ total = sum(self.four_way.values()) or 1
68
+ return {
69
+ "scorer_version": self.scorer_version,
70
+ "mode": self.mode,
71
+ "overall": self.overall.to_dict(),
72
+ "by_source": {k: v.to_dict() for k, v in sorted(self.by_source.items())},
73
+ "by_category": {k: v.to_dict() for k, v in sorted(self.by_category.items())},
74
+ "four_way": {
75
+ bucket: {"count": n, "rate": round(n / total, 4)}
76
+ for bucket, n in sorted(self.four_way.items())
77
+ },
78
+ "all_null_floor": round(self.all_null_floor, 4),
79
+ }
80
+
81
+
82
+ def _add(strata: dict[str, Stratum], key: str, r: FieldResult) -> None:
83
+ s = strata.setdefault(key, Stratum())
84
+ s.fields += 1
85
+ s.passed += int(r.passed)
86
+ s.weighted += r.weighted_score
87
+ s.four_way[r.bucket] += 1
88
+
89
+
90
+ def build_report(docs: list[DocResult], mode: str = "unspecified") -> Report:
91
+ rep = Report(mode=mode)
92
+ empty_expected = 0
93
+ total = 0
94
+ doc_counts_source: dict[str, int] = defaultdict(int)
95
+ doc_counts_cat: dict[str, int] = defaultdict(int)
96
+
97
+ for doc in docs:
98
+ doc_counts_source[doc.source] += 1
99
+ doc_counts_cat[doc.category] += 1
100
+ for r in doc.fields:
101
+ total += 1
102
+ rep.overall.fields += 1
103
+ rep.overall.passed += int(r.passed)
104
+ rep.overall.weighted += r.weighted_score
105
+ rep.four_way[r.bucket] += 1
106
+ _add(rep.by_source, doc.source, r)
107
+ _add(rep.by_category, doc.category, r)
108
+ if is_empty(r.expected):
109
+ empty_expected += 1
110
+
111
+ rep.overall.docs = len(docs)
112
+ for src, n in doc_counts_source.items():
113
+ rep.by_source.setdefault(src, Stratum()).docs = n
114
+ for cat, n in doc_counts_cat.items():
115
+ rep.by_category.setdefault(cat, Stratum()).docs = n
116
+
117
+ # All-null floor: an empty extractor scores exactly the empty-GT fields
118
+ # (they land in `correct_absence`); everything else becomes a miss.
119
+ rep.all_null_floor = empty_expected / total if total else 0.0
120
+ return rep
fieldbench/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """FieldBench CLI: score prediction files against the corpus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .aggregate import build_report
12
+ from .corpus import score_corpus
13
+ from .run import run_corpus
14
+
15
+
16
+ def _load_runner(ref: str):
17
+ """Load a runner from `module:attr`. attr is a Runner instance or a
18
+ zero-arg factory that returns one."""
19
+ if ":" not in ref:
20
+ raise ValueError(f"--runner must be 'module:attr', got {ref!r}")
21
+ mod_name, attr = ref.split(":", 1)
22
+ obj = getattr(importlib.import_module(mod_name), attr)
23
+ if hasattr(obj, "extract"):
24
+ return obj
25
+ return obj() # factory
26
+
27
+
28
+ def _print_table(report_dict: dict) -> None:
29
+ o = report_dict["overall"]
30
+ print(f"\nFieldBench (scorer {report_dict['scorer_version']})")
31
+ print("=" * 52)
32
+ print(f"Overall: {o['accuracy']:.1%} ({o['passed']}/{o['fields']} fields, {o['docs']} docs)")
33
+ print(f"All-null floor: {report_dict['all_null_floor']:.1%} <- a `{{}}`-emitting baseline scores this free")
34
+
35
+ print("\nBy source:")
36
+ for src, s in report_dict["by_source"].items():
37
+ print(f" {src:<12} {s['accuracy']:.1%} ({s['fields']} fields, {s['docs']} docs)")
38
+
39
+ print("\nFour-way outcomes:")
40
+ for bucket, v in report_dict["four_way"].items():
41
+ print(f" {bucket:<16} {v['count']:>6} ({v['rate']:.1%})")
42
+
43
+ print("\nBy category:")
44
+ for cat, s in sorted(report_dict["by_category"].items(), key=lambda kv: kv[1]["accuracy"]):
45
+ print(f" {cat:<24} {s['accuracy']:.1%} ({s['fields']} fields)")
46
+ print()
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ parser = argparse.ArgumentParser(prog="fieldbench")
51
+ sub = parser.add_subparsers(dest="command", required=True)
52
+
53
+ score = sub.add_parser("score", help="Score prediction files against the corpus")
54
+ score.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
55
+ score.add_argument("--results", required=True, type=Path, help="Directory of <stem>.json predictions")
56
+ score.add_argument("--category", default=None, help="Score a single category only")
57
+ score.add_argument(
58
+ "--mode", default="unspecified", help="Label for which representation the predictions came from (markdown/source/...)"
59
+ )
60
+ score.add_argument("--fuzzy-threshold", type=float, default=0.0, help="Off (0.0) for the official metric")
61
+ score.add_argument("--json", action="store_true", help="Emit the full report as JSON")
62
+ score.add_argument(
63
+ "--allow-missing",
64
+ action="store_true",
65
+ help="Do not fail when prediction files are missing (they are scored as all-null)",
66
+ )
67
+
68
+ run = sub.add_parser("run", help="Run an extractor over the corpus, writing per-doc predictions")
69
+ run.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
70
+ run.add_argument("--out", required=True, type=Path, help="Output dir for <stem>.json predictions")
71
+ run.add_argument("--runner", required=True, help="Runner as 'module:attr' (instance or zero-arg factory)")
72
+ run.add_argument("--mode", default="markdown", help="Which representation to feed (markdown/text/source)")
73
+ run.add_argument("--category", default=None, help="Run a single category only")
74
+ run.add_argument("--limit", type=int, default=None, help="Stop after N docs (smoke testing)")
75
+ run.add_argument("--no-resume", action="store_true", help="Re-run docs even if a prediction file exists")
76
+
77
+ args = parser.parse_args(argv)
78
+
79
+ if args.command == "run":
80
+ if not args.corpus.is_dir():
81
+ print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
82
+ return 2
83
+ try:
84
+ runner = _load_runner(args.runner)
85
+ except (ValueError, ImportError, AttributeError) as exc:
86
+ print(f"error: could not load runner {args.runner!r}: {exc}", file=sys.stderr)
87
+ return 2
88
+ stats = run_corpus(
89
+ args.corpus,
90
+ runner,
91
+ args.out,
92
+ mode=args.mode,
93
+ category=args.category,
94
+ resume=not args.no_resume,
95
+ limit=args.limit,
96
+ on_progress=lambda stem, status: print(f" {stem}: {status}", file=sys.stderr),
97
+ )
98
+ print(
99
+ f"\nwrote {stats.written}, skipped {stats.skipped} (resume), "
100
+ f"errors {stats.errors}, no-representation {stats.no_representation}"
101
+ )
102
+ if stats.error_stems:
103
+ print(f"error docs: {', '.join(stats.error_stems[:10])}"
104
+ f"{'…' if len(stats.error_stems) > 10 else ''}", file=sys.stderr)
105
+ return 1 if (stats.errors or stats.no_representation) else 0
106
+
107
+ if args.command == "score":
108
+ if not args.corpus.is_dir():
109
+ print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
110
+ return 2
111
+ docs, missing = score_corpus(
112
+ args.corpus, args.results, category=args.category, fuzzy_threshold=args.fuzzy_threshold
113
+ )
114
+ if not docs:
115
+ print("error: no scorable documents found", file=sys.stderr)
116
+ return 2
117
+ report = build_report(docs, mode=args.mode).to_dict()
118
+ if args.json:
119
+ print(json.dumps(report, indent=2))
120
+ else:
121
+ _print_table(report)
122
+ if missing and not args.allow_missing:
123
+ print(
124
+ f"error: {missing} prediction file(s) missing — scored as all-null. "
125
+ f"Re-run with complete results, or pass --allow-missing to override.",
126
+ file=sys.stderr,
127
+ )
128
+ return 1
129
+ return 0
130
+ return 2
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
fieldbench/corpus.py ADDED
@@ -0,0 +1,167 @@
1
+ """Load the FieldBench corpus and score prediction files against it.
2
+
3
+ Supports two on-disk layouts so scoring keeps working across the migration to
4
+ the source-first structure (see corpus-structure.md):
5
+
6
+ Flat (current):
7
+ <category>/documents/<stem>.md
8
+ <category>/expected/<stem>.expected.json
9
+ <category>/manifests/<stem>.json
10
+
11
+ Source-first (target):
12
+ <category>/documents/<stem>/meta.json
13
+ <category>/documents/<stem>/repr/markdown.md (+ other representations)
14
+ <category>/documents/<stem>/source.<ext>
15
+ <category>/expected/<stem>.expected.json (GT unchanged, either layout)
16
+
17
+ Predictions: a directory of `<stem>.json` files, each a flat {field: value} map
18
+ (the zero-dependency format any system can emit). A missing prediction file is
19
+ scored as all-null, never silently skipped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+ import yaml
28
+
29
+ from .aggregate import DocResult
30
+ from .scoring import compare_field
31
+
32
+
33
+ def _read_json(path: Path) -> dict | None:
34
+ try:
35
+ return json.loads(path.read_text())
36
+ except (OSError, json.JSONDecodeError):
37
+ return None
38
+
39
+
40
+ def _load_manifest(cat_dir: Path, stem: str) -> dict | None:
41
+ """Dual-path manifest lookup: per-doc meta.json (new) → manifests/ (flat)."""
42
+ meta = cat_dir / "documents" / stem / "meta.json"
43
+ if meta.exists():
44
+ return _read_json(meta)
45
+ return _read_json(cat_dir / "manifests" / f"{stem}.json")
46
+
47
+
48
+ def _source_of(manifest: dict) -> str:
49
+ """real | synthetic | unknown. Explicit `source` wins; else derive from format."""
50
+ src = str(manifest.get("source", "")).strip().lower()
51
+ if src in ("real", "synthetic"):
52
+ return src
53
+ fmt = str(manifest.get("source_format") or manifest.get("original_format", "")).lower()
54
+ if not fmt:
55
+ return "unknown"
56
+ return "synthetic" if "synth" in fmt else "real"
57
+
58
+
59
+ def discover_categories(root: Path) -> list[str]:
60
+ cats = []
61
+ for entry in sorted(root.iterdir()):
62
+ if not entry.is_dir() or entry.name.startswith((".", "_")):
63
+ continue
64
+ if (entry / "documents").is_dir() and (entry / "expected").is_dir():
65
+ cats.append(entry.name)
66
+ return cats
67
+
68
+
69
+ def _schema_mappings(root: Path, schema_ref: str | None, cache: dict) -> dict:
70
+ """{field_name: mappings} from a schema YAML (`fields.<name>.mappings`).
71
+
72
+ Enum-alias folding is what lets `10K/A` match GT `10-K/A`. Best-effort:
73
+ a missing/unreadable schema yields no mappings, never an error.
74
+ """
75
+ if not schema_ref:
76
+ return {}
77
+ if schema_ref in cache:
78
+ return cache[schema_ref]
79
+ out: dict = {}
80
+ path = root / schema_ref
81
+ try:
82
+ schema = yaml.safe_load(path.read_text())
83
+ for name, spec in (schema.get("fields") or {}).items():
84
+ if isinstance(spec, dict) and isinstance(spec.get("mappings"), dict):
85
+ out[name] = spec["mappings"]
86
+ except (OSError, yaml.YAMLError, AttributeError):
87
+ out = {}
88
+ cache[schema_ref] = out
89
+ return out
90
+
91
+
92
+ def _iter_docs(root: Path, category: str):
93
+ cat = root / category
94
+ for expected_path in sorted((cat / "expected").glob("*.expected.json")):
95
+ stem = expected_path.name[: -len(".expected.json")]
96
+ expected = _read_json(expected_path)
97
+ manifest = _load_manifest(cat, stem)
98
+ if expected is None or manifest is None:
99
+ continue
100
+ yield stem, expected, manifest
101
+
102
+
103
+ def representation_path(cat_dir: Path, stem: str, manifest: dict, mode: str) -> Path | None:
104
+ """Resolve the file a prediction generator should read for `mode`.
105
+
106
+ New layout: manifest.representations[mode].path (or source.* for mode='source').
107
+ Flat layout: documents/<stem>.md for mode in {markdown, text}.
108
+ """
109
+ doc_dir = cat_dir / "documents" / stem
110
+ if mode == "source":
111
+ artifact = manifest.get("source_artifact")
112
+ return (doc_dir / artifact) if artifact else None
113
+ reps = manifest.get("representations")
114
+ if isinstance(reps, dict) and mode in reps and isinstance(reps[mode], dict):
115
+ p = doc_dir / reps[mode]["path"]
116
+ return p if p.exists() else None
117
+ flat = cat_dir / "documents" / f"{stem}.md" # flat layout has only markdown
118
+ return flat if (mode in ("markdown", "text") and flat.exists()) else None
119
+
120
+
121
+ def list_documents(corpus_root: Path, mode: str = "markdown", category: str | None = None):
122
+ """Yield (stem, category, representation_path) for baseline/prediction runners."""
123
+ categories = [category] if category else discover_categories(corpus_root)
124
+ for cat in categories:
125
+ for stem, _expected, manifest in _iter_docs(corpus_root, cat):
126
+ yield stem, cat, representation_path(corpus_root / cat, stem, manifest, mode)
127
+
128
+
129
+ def _load_prediction(results_dir: Path, stem: str) -> dict:
130
+ data = _read_json(results_dir / f"{stem}.json")
131
+ if isinstance(data, dict) and isinstance(data.get("fields"), dict):
132
+ return data["fields"] # accept a {"fields": {...}} wrapper
133
+ return data if isinstance(data, dict) else {}
134
+
135
+
136
+ def score_corpus(
137
+ corpus_root: Path,
138
+ results_dir: Path,
139
+ category: str | None = None,
140
+ fuzzy_threshold: float = 0.0,
141
+ ) -> tuple[list[DocResult], int]:
142
+ """Score every document. Returns (doc_results, missing_prediction_count).
143
+
144
+ Assert `missing_prediction_count == 0` before publishing any number — a
145
+ missing file is scored as all-null, not skipped, so it can't silently
146
+ inflate a system's accuracy.
147
+ """
148
+ categories = [category] if category else discover_categories(corpus_root)
149
+ docs: list[DocResult] = []
150
+ missing = 0
151
+ schema_cache: dict = {}
152
+
153
+ for cat in categories:
154
+ for stem, expected, manifest in _iter_docs(corpus_root, cat):
155
+ if not (results_dir / f"{stem}.json").exists():
156
+ missing += 1
157
+ prediction = _load_prediction(results_dir, stem)
158
+ mappings = _schema_mappings(corpus_root, manifest.get("schema"), schema_cache)
159
+ fields = [
160
+ compare_field(
161
+ name, exp, prediction.get(name), fuzzy_threshold=fuzzy_threshold, mappings=mappings.get(name)
162
+ )
163
+ for name, exp in expected.items()
164
+ ]
165
+ docs.append(DocResult(stem, cat, _source_of(manifest), fields))
166
+
167
+ return docs, missing
fieldbench/run.py ADDED
@@ -0,0 +1,209 @@
1
+ """Drive an extractor over the corpus and write per-doc predictions.
2
+
3
+ The other half of the benchmark loop: `fieldbench run` produces the `<stem>.json`
4
+ prediction files that `fieldbench score` reads. Extractor-agnostic — supply any
5
+ Runner (turns document text + schema into a {field: value} dict). A schema-driven
6
+ LLM runner is provided; model-SDK wiring lives in examples/ so the core stays
7
+ dependency-light. Runs are resumable (existing prediction files are skipped) and
8
+ never invent results — a runner error writes nothing, so the doc is scored as
9
+ all-null (a real miss), not silently dropped.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Callable, Protocol
19
+
20
+ import yaml
21
+
22
+ from .corpus import _iter_docs, discover_categories, representation_path
23
+ from .scoring import is_empty
24
+
25
+
26
+ class Runner(Protocol):
27
+ """Anything that turns a document + its schema into a {field: value} dict."""
28
+
29
+ def extract(self, doc_text: str, schema: dict, stem: str) -> dict: ...
30
+
31
+
32
+ @dataclass
33
+ class RunStats:
34
+ written: int = 0
35
+ skipped: int = 0 # already present (resume)
36
+ errors: int = 0
37
+ no_representation: int = 0
38
+ error_stems: list = field(default_factory=list)
39
+
40
+
41
+ def _schema_field_names(schema: dict) -> list[str]:
42
+ return list((schema.get("fields") or {}).keys())
43
+
44
+
45
+ def build_extraction_prompt(doc_text: str, schema: dict) -> str:
46
+ """Generic schema-driven extraction prompt. No domain assumptions."""
47
+ lines = []
48
+ for name, spec in (schema.get("fields") or {}).items():
49
+ spec = spec if isinstance(spec, dict) else {}
50
+ typ = spec.get("type", "string")
51
+ desc = spec.get("description", "")
52
+ opts = spec.get("options")
53
+ suffix = f" (one of: {', '.join(map(str, opts))})" if isinstance(opts, list) and opts else ""
54
+ lines.append(f"- {name} ({typ}): {desc}{suffix}")
55
+ fields_block = "\n".join(lines)
56
+ return (
57
+ "Extract structured data from the document below.\n"
58
+ "Return ONLY a JSON object with EXACTLY these fields. "
59
+ "Use null when a field is not present in the document — do not guess.\n\n"
60
+ f"Fields:\n{fields_block}\n\n"
61
+ f"Document:\n{doc_text}\n\nJSON:"
62
+ )
63
+
64
+
65
+ def parse_json_response(text: str) -> dict:
66
+ """Robustly pull a JSON object out of an LLM response."""
67
+ text = text.strip()
68
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
69
+ try:
70
+ obj = json.loads(text)
71
+ except json.JSONDecodeError:
72
+ start, depth, end = text.find("{"), 0, -1
73
+ if start < 0:
74
+ return {}
75
+ for i in range(start, len(text)):
76
+ depth += text[i] == "{"
77
+ depth -= text[i] == "}"
78
+ if depth == 0:
79
+ end = i + 1
80
+ break
81
+ if end < 0:
82
+ return {}
83
+ try:
84
+ obj = json.loads(text[start:end])
85
+ except json.JSONDecodeError:
86
+ return {}
87
+ return obj if isinstance(obj, dict) else {}
88
+
89
+
90
+ def window_text(text: str, size: int, overlap: int = 2000) -> list[str]:
91
+ """Split text into overlapping windows of `size` chars (overlap avoids
92
+ splitting a value across a boundary)."""
93
+ if len(text) <= size:
94
+ return [text]
95
+ step = max(1, size - overlap)
96
+ return [text[i : i + size] for i in range(0, len(text), step)]
97
+
98
+
99
+ def merge_field_values(values: list) -> object:
100
+ """Merge one field's values across windows: concat+dedup lists, first
101
+ non-empty dict, else first non-empty scalar."""
102
+ non_empty = [v for v in values if not is_empty(v)]
103
+ if not non_empty:
104
+ return None
105
+ if any(isinstance(v, list) for v in non_empty):
106
+ out, seen = [], set()
107
+ for v in non_empty:
108
+ for item in v if isinstance(v, list) else [v]:
109
+ key = json.dumps(item, sort_keys=True, default=str)
110
+ if key not in seen:
111
+ seen.add(key)
112
+ out.append(item)
113
+ return out
114
+ for v in non_empty:
115
+ if isinstance(v, dict):
116
+ return v
117
+ return non_empty[0]
118
+
119
+
120
+ class LLMRunner:
121
+ """Schema-driven runner over any `complete(prompt) -> str` callable.
122
+
123
+ Keeps model SDKs out of the core: pass a completion function (see
124
+ examples/). Output is filtered to the schema's declared fields.
125
+
126
+ `max_doc_chars` (None = off) makes it windowed: a document longer than the
127
+ budget is split into overlapping windows, each extracted, then merged
128
+ field-by-field — a fair long-document baseline that survives context limits.
129
+ """
130
+
131
+ def __init__(self, complete: Callable[[str], str], max_doc_chars: int | None = None, overlap: int = 2000):
132
+ self._complete = complete
133
+ self._max_doc_chars = max_doc_chars
134
+ self._overlap = overlap
135
+
136
+ def _extract_once(self, doc_text: str, schema: dict, allowed: set) -> dict:
137
+ parsed = parse_json_response(self._complete(build_extraction_prompt(doc_text, schema)))
138
+ return {k: v for k, v in parsed.items() if k in allowed} if allowed else parsed
139
+
140
+ def extract(self, doc_text: str, schema: dict, stem: str) -> dict:
141
+ allowed = set(_schema_field_names(schema))
142
+ if self._max_doc_chars and len(doc_text) > self._max_doc_chars:
143
+ windows = window_text(doc_text, self._max_doc_chars, self._overlap)
144
+ preds = [self._extract_once(w, schema, allowed) for w in windows]
145
+ names = allowed or {k for p in preds for k in p}
146
+ return {name: merge_field_values([p.get(name) for p in preds]) for name in names}
147
+ return self._extract_once(doc_text, schema, allowed)
148
+
149
+
150
+ def _load_schema(root: Path, schema_ref: str | None, cache: dict) -> dict:
151
+ if not schema_ref:
152
+ return {}
153
+ if schema_ref in cache:
154
+ return cache[schema_ref]
155
+ try:
156
+ schema = yaml.safe_load((root / schema_ref).read_text()) or {}
157
+ except (OSError, yaml.YAMLError):
158
+ schema = {}
159
+ cache[schema_ref] = schema
160
+ return schema
161
+
162
+
163
+ def run_corpus(
164
+ corpus_root: Path,
165
+ runner: Runner,
166
+ out_dir: Path,
167
+ mode: str = "markdown",
168
+ category: str | None = None,
169
+ resume: bool = True,
170
+ limit: int | None = None,
171
+ on_progress: Callable[[str, str], None] | None = None,
172
+ ) -> RunStats:
173
+ """Run `runner` over the corpus, writing out_dir/<stem>.json per doc."""
174
+ out_dir.mkdir(parents=True, exist_ok=True)
175
+ stats = RunStats()
176
+ schema_cache: dict = {}
177
+ categories = [category] if category else discover_categories(corpus_root)
178
+ done = 0
179
+
180
+ for cat in categories:
181
+ cat_dir = corpus_root / cat
182
+ for stem, _expected, manifest in _iter_docs(corpus_root, cat): # _expected is GT — NOT used at run time
183
+ if limit is not None and done >= limit:
184
+ return stats
185
+ out_path = out_dir / f"{stem}.json"
186
+ if resume and out_path.exists():
187
+ stats.skipped += 1
188
+ continue
189
+ repr_path = representation_path(cat_dir, stem, manifest, mode)
190
+ if repr_path is None or not repr_path.exists():
191
+ stats.no_representation += 1
192
+ if on_progress:
193
+ on_progress(stem, "no-representation")
194
+ continue
195
+ schema = _load_schema(corpus_root, manifest.get("schema"), schema_cache)
196
+ done += 1 # counts any attempted doc, so --limit works even when docs error
197
+ try:
198
+ prediction = runner.extract(repr_path.read_text(), schema, stem)
199
+ out_path.write_text(json.dumps(prediction, ensure_ascii=False, indent=2))
200
+ stats.written += 1
201
+ if on_progress:
202
+ on_progress(stem, "ok")
203
+ except Exception as exc: # noqa: BLE001 — a runner failure must not abort the whole run
204
+ stats.errors += 1
205
+ stats.error_stems.append(stem)
206
+ if on_progress:
207
+ on_progress(stem, f"error: {exc}")
208
+
209
+ return stats
fieldbench/scoring.py ADDED
@@ -0,0 +1,333 @@
1
+ """Canonical field-level comparison for the FieldBench benchmark.
2
+
3
+ This is the single source of truth for how an extracted value is compared to
4
+ ground truth. It ports the semantics of Koji's ``cli/test_runner.py`` scorer
5
+ (binary pass/fail + four-way null semantics, array F1, enum/mapping folding,
6
+ punctuation-tolerant scalars) and makes the four-way outcome an explicit,
7
+ first-class tag on every field so the aggregate can report a hallucination /
8
+ miss / correct-absence breakdown, not just accuracy.
9
+
10
+ Fully generic: no field names, document types, or domain knowledge.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from collections import Counter
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal
20
+
21
+ Bucket = Literal["correct_absence", "hallucination", "miss", "match", "wrong_value"]
22
+
23
+ SCORER_VERSION = "0.2.1"
24
+
25
+
26
+ # ── Normalization helpers ─────────────────────────────────────────────
27
+
28
+
29
+ def _levenshtein(a: str, b: str) -> int:
30
+ if len(a) < len(b):
31
+ return _levenshtein(b, a)
32
+ if not b:
33
+ return len(a)
34
+ prev = list(range(len(b) + 1))
35
+ for i, ca in enumerate(a):
36
+ curr = [i + 1]
37
+ for j, cb in enumerate(b):
38
+ curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (ca != cb)))
39
+ prev = curr
40
+ return prev[-1]
41
+
42
+
43
+ # Common organizational designators. Folding trailing occurrences lets
44
+ # "Microsoft Corp" match "Microsoft Corporation" and "3M Co" match "3M Company"
45
+ # under the exact metric — a document prints the full legal form while an
46
+ # authoritative index (e.g. EDGAR) stores an abbreviation.
47
+ _ORG_SUFFIXES = {
48
+ "corp", "corporation", "co", "company", "inc", "incorporated", "ltd",
49
+ "limited", "llc", "llp", "lp", "plc", "pllc", "pc", "sa", "ag", "gmbh",
50
+ "nv", "bv", "ab", "oy", "spa",
51
+ }
52
+
53
+
54
+ def _fold_org_suffix(normalized: str) -> str:
55
+ """Strip trailing organizational designators from a punctuation-normalized
56
+ string (already lowercased, single-spaced). Returns the folded core."""
57
+ toks = normalized.split()
58
+ while toks and toks[-1] in _ORG_SUFFIXES:
59
+ toks.pop()
60
+ return " ".join(toks)
61
+
62
+
63
+ def string_similarity(a: str, b: str) -> float:
64
+ """Levenshtein ratio in [0,1], case-insensitive after trimming."""
65
+ a = a.strip().lower()
66
+ b = b.strip().lower()
67
+ if a == b:
68
+ return 1.0
69
+ max_len = max(len(a), len(b))
70
+ if max_len == 0:
71
+ return 1.0
72
+ return 1.0 - _levenshtein(a, b) / max_len
73
+
74
+
75
+ def normalize_date(value: Any) -> str | None:
76
+ """Normalize to YYYY-MM-DD, or None if not a recognizable date."""
77
+ if not isinstance(value, str):
78
+ return None
79
+ s = value.strip()
80
+ m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})$", s)
81
+ if m:
82
+ return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
83
+ m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", s)
84
+ if m:
85
+ return f"{m.group(3)}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
86
+ m = re.match(r"^(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})$", s)
87
+ if m:
88
+ return f"{m.group(3)}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
89
+ return None
90
+
91
+
92
+ def to_number(value: Any) -> float | None:
93
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
94
+ return float(value)
95
+ if isinstance(value, str):
96
+ cleaned = value.replace("$", "").replace(",", "").strip()
97
+ try:
98
+ return float(cleaned)
99
+ except ValueError:
100
+ return None
101
+ return None
102
+
103
+
104
+ def is_empty(value: Any) -> bool:
105
+ """None, empty string, empty list, empty dict → 'no value'."""
106
+ if value is None:
107
+ return True
108
+ if isinstance(value, str) and value.strip() == "":
109
+ return True
110
+ if isinstance(value, (list, dict)) and len(value) == 0:
111
+ return True
112
+ return False
113
+
114
+
115
+ def _normalize_value(value: Any) -> Any:
116
+ if is_empty(value):
117
+ return None
118
+ if isinstance(value, bool):
119
+ return value
120
+ if isinstance(value, (int, float)):
121
+ return float(value)
122
+ if isinstance(value, str):
123
+ d = normalize_date(value)
124
+ if d is not None:
125
+ return d
126
+ n = to_number(value)
127
+ if n is not None:
128
+ return n
129
+ return value.strip().lower()
130
+ if isinstance(value, dict):
131
+ out: dict = {}
132
+ for k, v in value.items():
133
+ if isinstance(k, str) and k.startswith("__"): # provenance keys
134
+ continue
135
+ nv = _normalize_value(v)
136
+ if nv is not None:
137
+ out[k] = nv
138
+ return out or None
139
+ if isinstance(value, list):
140
+ norm = [_normalize_value(v) for v in value]
141
+ norm = [v for v in norm if v is not None]
142
+ if not norm:
143
+ return None
144
+ if all(isinstance(v, dict) for v in norm):
145
+ norm.sort(key=lambda v: json.dumps(v, sort_keys=True))
146
+ return norm
147
+ return value
148
+
149
+
150
+ def _normalize_for_set(items: list) -> list[str]:
151
+ keys = []
152
+ for item in items:
153
+ n = _normalize_value(item)
154
+ keys.append(json.dumps(n, sort_keys=True) if isinstance(n, dict) else json.dumps(n))
155
+ return keys
156
+
157
+
158
+ def _dict_key_overlap(a: dict, b: dict) -> float:
159
+ na = _normalize_value(a)
160
+ nb = _normalize_value(b)
161
+ if not isinstance(na, dict) or not isinstance(nb, dict):
162
+ return 1.0 if json.dumps(na, sort_keys=True, default=str) == json.dumps(nb, sort_keys=True, default=str) else 0.0
163
+ all_keys = set(na) | set(nb)
164
+ if not all_keys:
165
+ return 1.0
166
+ matches = sum(
167
+ 1
168
+ for k in all_keys
169
+ if json.dumps(na.get(k), sort_keys=True, default=str) == json.dumps(nb.get(k), sort_keys=True, default=str)
170
+ )
171
+ return matches / len(all_keys)
172
+
173
+
174
+ def _array_of_dicts_similarity(expected: list, actual: list) -> float:
175
+ if not expected:
176
+ return 1.0 if not actual else 0.0
177
+ remaining = list(range(len(actual)))
178
+ total = 0.0
179
+ for exp_item in expected:
180
+ best, best_idx = 0.0, -1
181
+ for j in remaining:
182
+ sim = _dict_key_overlap(exp_item, actual[j])
183
+ if sim > best:
184
+ best, best_idx = sim, j
185
+ total += best
186
+ if best_idx >= 0:
187
+ remaining.remove(best_idx)
188
+ return total / len(expected)
189
+
190
+
191
+ def array_f1(expected: list, actual: list) -> float:
192
+ """Element-wise F1 in [0,1] (quality-weighted precision/recall)."""
193
+ if not expected and not actual:
194
+ return 1.0
195
+ if not expected or not actual:
196
+ return 0.0
197
+ if all(isinstance(v, dict) for v in expected) and all(isinstance(v, dict) for v in actual):
198
+ recall = _array_of_dicts_similarity(expected, actual)
199
+ precision = _array_of_dicts_similarity(actual, expected)
200
+ else:
201
+ exp_c = Counter(_normalize_for_set(expected))
202
+ act_c = Counter(_normalize_for_set(actual))
203
+ matched = sum((exp_c & act_c).values())
204
+ recall = matched / len(expected)
205
+ precision = matched / len(actual)
206
+ if precision + recall == 0:
207
+ return 0.0
208
+ return 2 * precision * recall / (precision + recall)
209
+
210
+
211
+ def resolve_mapping(value: str, mappings: dict) -> str:
212
+ """Fold a value to its canonical form via schema-declared enum aliases."""
213
+
214
+ def fold(s: object) -> str:
215
+ return re.sub(r"\s+", " ", str(s).strip().lower())
216
+
217
+ v = fold(value)
218
+ for canonical, aliases in mappings.items():
219
+ if v == fold(canonical):
220
+ return canonical
221
+ if isinstance(aliases, list) and any(v == fold(a) for a in aliases):
222
+ return canonical
223
+ return value
224
+
225
+
226
+ # ── Result ────────────────────────────────────────────────────────────
227
+
228
+
229
+ @dataclass
230
+ class FieldResult:
231
+ field_name: str
232
+ passed: bool
233
+ bucket: Bucket
234
+ expected: Any = None
235
+ actual: Any = None
236
+ detail: str = ""
237
+ score: float | None = None # explicit partial credit; None → derive from `passed`
238
+
239
+ @property
240
+ def weighted_score(self) -> float:
241
+ if self.score is not None:
242
+ return self.score
243
+ return 1.0 if self.passed else 0.0
244
+
245
+
246
+ # ── Core comparison ───────────────────────────────────────────────────
247
+
248
+
249
+ def compare_field(
250
+ field_name: str,
251
+ expected: Any,
252
+ actual: Any,
253
+ fuzzy_threshold: float = 0.0,
254
+ mappings: dict | None = None,
255
+ ) -> FieldResult:
256
+ """Compare one expected value against one extracted value.
257
+
258
+ Four-way null semantics run first, in order:
259
+ 1. both empty → correct_absence (PASS)
260
+ 2. expected empty only → hallucination (FAIL)
261
+ 3. actual empty only → miss (FAIL)
262
+ 4. both present → type-aware compare → match / wrong_value
263
+ """
264
+ exp_empty, act_empty = is_empty(expected), is_empty(actual)
265
+
266
+ if exp_empty and act_empty:
267
+ return FieldResult(field_name, True, "correct_absence", expected, actual, "correctly absent")
268
+ if exp_empty and not act_empty:
269
+ return FieldResult(field_name, False, "hallucination", expected, actual, f"hallucinated: expected null, got {actual!r}")
270
+ if act_empty:
271
+ return FieldResult(field_name, False, "miss", expected, actual, "missing from actual")
272
+
273
+ def matched(detail: str = "", score: float | None = None) -> FieldResult:
274
+ return FieldResult(field_name, True, "match", expected, actual, detail, score)
275
+
276
+ def wrong(detail: str, score: float | None = None) -> FieldResult:
277
+ return FieldResult(field_name, False, "wrong_value", expected, actual, detail, score)
278
+
279
+ # Date
280
+ ed, ad = normalize_date(expected), normalize_date(actual)
281
+ if ed is not None and ad is not None:
282
+ return matched() if ed == ad else wrong(f"expected {ed}, got {ad}")
283
+
284
+ # Number (absolute tolerance 0.01 — cent-exact by design)
285
+ en, an = to_number(expected), to_number(actual)
286
+ if en is not None and an is not None:
287
+ return matched() if round(abs(en - an), 10) <= 0.01 else wrong(f"expected {expected}, got {actual}")
288
+
289
+ # Array (element-wise F1; strict pass = exact-set, else fuzzy_threshold)
290
+ if isinstance(expected, list) and isinstance(actual, list):
291
+ f1 = array_f1(expected, actual)
292
+ exp_keys = sorted(_normalize_for_set(expected))
293
+ act_keys = sorted(_normalize_for_set(actual))
294
+ if exp_keys == act_keys:
295
+ return matched(score=1.0)
296
+ if fuzzy_threshold > 0 and expected:
297
+ matched_n = sum(1 for k in exp_keys if k in act_keys)
298
+ ratio = matched_n / len(exp_keys)
299
+ if ratio >= fuzzy_threshold:
300
+ return matched(f"fuzzy array match ({matched_n}/{len(exp_keys)})", score=f1)
301
+ if (
302
+ len(expected) == len(actual)
303
+ and all(isinstance(v, dict) for v in expected)
304
+ and all(isinstance(v, dict) for v in actual)
305
+ and _array_of_dicts_similarity(expected, actual) >= fuzzy_threshold
306
+ ):
307
+ return matched("fuzzy structural match", score=f1)
308
+ return wrong(f"array items differ ({f1:.0%} element F1)", score=f1)
309
+
310
+ # Scalar / string
311
+ if isinstance(expected, str) and isinstance(actual, str):
312
+ e, a = expected, actual
313
+ if mappings:
314
+ e, a = resolve_mapping(e, mappings), resolve_mapping(a, mappings)
315
+ if e.strip().lower() == a.strip().lower():
316
+ return matched()
317
+ ep = re.sub(r"[^a-z0-9]+", " ", e.lower()).strip()
318
+ ea = re.sub(r"[^a-z0-9]+", " ", a.lower()).strip()
319
+ if ep and ep == ea:
320
+ return matched() # punctuation-only difference
321
+ ef, af = _fold_org_suffix(ep), _fold_org_suffix(ea)
322
+ if ef and ef == af:
323
+ # equal once trailing org designators (Corp/Corporation/Co/…) are folded;
324
+ # ef non-empty guards against two bare designators folding to nothing
325
+ return matched("organizational-suffix-equivalent")
326
+ if fuzzy_threshold > 0 and string_similarity(e, a) >= fuzzy_threshold:
327
+ return matched(f"fuzzy match ({string_similarity(e, a):.0%})")
328
+ return wrong(f"expected {expected!r}, got {actual!r}")
329
+
330
+ # Type mismatch or other → strict equality fallback
331
+ if _normalize_value(expected) == _normalize_value(actual):
332
+ return matched()
333
+ return wrong(f"expected {expected!r}, got {actual!r}")
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: fieldbench
3
+ Version: 0.2.1
4
+ Summary: A cross-domain, field-level benchmark for schema-driven document extraction
5
+ Author: Frank Thomas
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fieldbench/fieldbench
8
+ Project-URL: Corpus, https://github.com/fieldbench/corpus
9
+ Keywords: benchmark,document-extraction,information-extraction,evaluation,nlp
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: pyyaml>=6
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: ruff>=0.6; extra == "dev"
22
+ Provides-Extra: openai
23
+ Requires-Dist: openai>=1.0; extra == "openai"
24
+ Provides-Extra: anthropic
25
+ Requires-Dist: anthropic>=0.30; extra == "anthropic"
26
+ Provides-Extra: llamaindex
27
+ Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
28
+ Requires-Dist: llama-index-llms-openai>=0.2; extra == "llamaindex"
29
+ Provides-Extra: langchain
30
+ Requires-Dist: langchain-openai>=0.2; extra == "langchain"
31
+ Dynamic: license-file
32
+
33
+ # FieldBench
34
+
35
+ A cross-domain, field-level benchmark for **schema-driven document extraction** — and the canonical scorer that goes with it.
36
+
37
+ Every document-extraction vendor claims 95%+ accuracy; almost none publish how they measure it. FieldBench makes extraction accuracy **falsifiable and comparable**: a shared corpus, a shared type-aware scorer, and a leaderboard anyone can submit to.
38
+
39
+ > **Status: alpha (v0.1).** The scorer is the first piece to land. The corpus lives at [`fieldbench/corpus`](https://github.com/fieldbench/corpus). Leaderboard and HuggingFace packaging are in progress.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install fieldbench # once published; for now: pip install -e .
45
+ ```
46
+
47
+ ## Run a baseline
48
+
49
+ `fieldbench run` drives any extractor over the corpus and writes the prediction files for you. It's extractor-agnostic — the whole integration surface is a `complete(prompt) -> str` callable (see `examples/openai_runner.py`):
50
+
51
+ ```bash
52
+ pip install "fieldbench[openai]"
53
+ OPENAI_API_KEY=... FIELDBENCH_MODEL=gpt-4o-mini \
54
+ fieldbench run --corpus ./corpus --out ./preds/gpt-4o-mini \
55
+ --runner examples.openai_runner:make_runner
56
+ ```
57
+
58
+ Runs are **resumable** (existing predictions are skipped) and **never invent results** — a runner error writes nothing, so that document is scored as a real miss, not dropped.
59
+
60
+ ## Score your system
61
+
62
+ Score prediction files — a flat `{field: value}` JSON named `<doc_id>.json` per document (produced by `fieldbench run` or by your own pipeline):
63
+
64
+ ```bash
65
+ fieldbench score --corpus /path/to/corpus --results /path/to/predictions/
66
+ ```
67
+
68
+ You get overall accuracy, the **all-null floor** (what an empty extractor scores for free), a **real-vs-synthetic** split, a **four-way outcome breakdown**, and a per-category table. `--json` emits the full report; `--category <name>` scopes to one category.
69
+
70
+ ## What makes the scoring type-aware
71
+
72
+ `compare_field` runs **four-way null semantics** first — the thing plain accuracy hides:
73
+
74
+ | Outcome | Meaning |
75
+ |---|---|
76
+ | `correct_absence` | field absent in GT, system correctly returned null |
77
+ | `hallucination` | field absent in GT, system invented a value |
78
+ | `miss` | field present in GT, system returned null |
79
+ | `match` / `wrong_value` | both present → type-aware comparison |
80
+
81
+ For present-vs-present it is tolerant where it should be and strict where it must be: numeric with cent-exact tolerance, date normalization, order-independent array F1 (partial credit), punctuation-insensitive strings, and schema enum-alias folding — but never masking a genuine content difference.
82
+
83
+ ## How to cite
84
+
85
+ ```bibtex
86
+ @misc{fieldbench,
87
+ title = {FieldBench: A Cross-Domain Benchmark for Schema-Driven Document Extraction},
88
+ author = {Thomas, Frank},
89
+ year = {2026},
90
+ note = {https://github.com/fieldbench}
91
+ }
92
+ ```
93
+ <!-- Replaced with the Zenodo DOI on release, and the paper citation once published. -->
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ pip install -e ".[dev]"
99
+ pytest # golden scorer tests
100
+ ruff check .
101
+ ```
102
+
103
+ The golden tests in `tests/` are the anti-drift contract: they pin the canonical scoring semantics that any conforming implementation (including Koji's) must match.
104
+
105
+ ## License
106
+
107
+ Code: MIT. The corpus is licensed per-source — see [`fieldbench/corpus`](https://github.com/fieldbench/corpus).
@@ -0,0 +1,12 @@
1
+ fieldbench/__init__.py,sha256=Uecv5-tI8eUfS0sCqm588502nAYrgLRWTHW9atp1p4w,575
2
+ fieldbench/aggregate.py,sha256=M4LymQ_Smw3QYPw4IUdFz-XPcot8U-Ai9yPXQy18rsk,4039
3
+ fieldbench/cli.py,sha256=TF0VZkx8oFSCamK692mTQyBVCbt9iFKMZKaFtOFfFew,5724
4
+ fieldbench/corpus.py,sha256=moljxHWUFPTTHpme8eEWH1hUz70iDqwYHvm3rUn-_EI,6341
5
+ fieldbench/run.py,sha256=IMacKpXZ2Rc-rrHhfCVqJMiWfJBRoa8oOFC33n6gG_M,8022
6
+ fieldbench/scoring.py,sha256=04EI2lBHYNa25lWiZZ6HTBfjoGYN4sfgXUoLZBrR_Nk,12491
7
+ fieldbench-0.2.1.dist-info/licenses/LICENSE,sha256=AVla6qeNWJpcwjF6XMS3ONSL978yto6oWyt6H6FrAUc,1069
8
+ fieldbench-0.2.1.dist-info/METADATA,sha256=zdgm7jXN5MFo0bEZ4dwI7P5nu0A2Y9mBpVKMcCfPyLg,4547
9
+ fieldbench-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ fieldbench-0.2.1.dist-info/entry_points.txt,sha256=qqwfIOAprmTIt1iJEO2VB6er7ov0CI537c0kGvzWWus,51
11
+ fieldbench-0.2.1.dist-info/top_level.txt,sha256=cMN0klfFO_qUf3aHFLI5KfZhHI3W2AUC5EaNJEywing,11
12
+ fieldbench-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fieldbench = fieldbench.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fieldbench