closebench 0.1.0__tar.gz
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.
- closebench-0.1.0/LICENSE +21 -0
- closebench-0.1.0/PKG-INFO +182 -0
- closebench-0.1.0/README.md +159 -0
- closebench-0.1.0/closebench/__init__.py +8 -0
- closebench-0.1.0/closebench/cli.py +104 -0
- closebench-0.1.0/closebench/grader.py +79 -0
- closebench-0.1.0/closebench/ledger.py +117 -0
- closebench-0.1.0/closebench/pack.py +31 -0
- closebench-0.1.0/closebench/runner.py +109 -0
- closebench-0.1.0/closebench/strings.py +589 -0
- closebench-0.1.0/closebench/tools.py +110 -0
- closebench-0.1.0/closebench/world.py +653 -0
- closebench-0.1.0/closebench.egg-info/PKG-INFO +182 -0
- closebench-0.1.0/closebench.egg-info/SOURCES.txt +19 -0
- closebench-0.1.0/closebench.egg-info/dependency_links.txt +1 -0
- closebench-0.1.0/closebench.egg-info/entry_points.txt +2 -0
- closebench-0.1.0/closebench.egg-info/requires.txt +3 -0
- closebench-0.1.0/closebench.egg-info/top_level.txt +1 -0
- closebench-0.1.0/pyproject.toml +40 -0
- closebench-0.1.0/setup.cfg +4 -0
- closebench-0.1.0/tests/test_closebench.py +133 -0
closebench-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 closebench contributors
|
|
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,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: closebench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic, seeded double-entry ledgers with planted closing errors and a programmatic grader — test fixtures for bookkeeping agents.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: accounting,bookkeeping,ledger,double-entry,month-end close,test-fixtures,benchmark,agent-evaluation,llm-agents,fintech
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# closebench
|
|
25
|
+
|
|
26
|
+
[](https://github.com/pahrya-arch/Closebench/actions/workflows/tests.yml)
|
|
27
|
+
|
|
28
|
+
Deterministic, seeded double-entry ledgers with planted month-end closing errors and a
|
|
29
|
+
programmatic grader. Zero dependencies. Built for testing bookkeeping agents.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from closebench.world import World
|
|
33
|
+
from closebench.tools import Session
|
|
34
|
+
from closebench.grader import grade
|
|
35
|
+
|
|
36
|
+
world = World(seed=7).build() # same seed → byte-identical books, every time
|
|
37
|
+
agent = Session(world) # the only surface an agent ever sees
|
|
38
|
+
|
|
39
|
+
print(agent.trial_balance()) # ...agent reads the books, documents, policy...
|
|
40
|
+
agent.post_entry("2026-11-30", "capitalise HW-7781", [
|
|
41
|
+
{"account": "1500", "debit": "175000.00"},
|
|
42
|
+
{"account": "6900", "credit": "175000.00"},
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
print(grade(world, agent))
|
|
46
|
+
# {'passed': False, 'score': 0.143, 'found': ['E3'], 'partial': [], 'missed': [...],
|
|
47
|
+
# 'spurious_entries': [], 'tb_balanced': True, ...}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Why this exists
|
|
51
|
+
|
|
52
|
+
If you are building an agent that closes the books, you need books to close — with
|
|
53
|
+
errors you planted yourself, so you know the answer. Real ERP exports are messy, slow,
|
|
54
|
+
legally encumbered and never reproducible. LLM-judged rubrics drift.
|
|
55
|
+
|
|
56
|
+
`closebench` gives you a company: eleven months of operations, a chart of accounts,
|
|
57
|
+
supporting documents, an accounting policy, and a fixed set of closing errors chosen by
|
|
58
|
+
the seed. The grader checks the agent's adjusting entries against the planted errors by
|
|
59
|
+
account and amount — no rubric, no judge model, no human in the loop.
|
|
60
|
+
|
|
61
|
+
Use it as a regression suite: run your agent over 50 seeds on every change and watch
|
|
62
|
+
`pass_rate` and `mean_score`.
|
|
63
|
+
|
|
64
|
+
## What a world contains
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Ledger | ~140 balanced entries across 11 months, 26-account chart, cash and inventory never negative |
|
|
69
|
+
| Documents | ~25: vendor invoices, contracts, bank statement, intercompany email, FX rate certificate, accounting policy |
|
|
70
|
+
| Planted errors | 6–9 per world in `hard` mode, 4–6 in `absence` mode |
|
|
71
|
+
| Distractors | legitimate transactions that look like errors — correcting them costs points |
|
|
72
|
+
| Decoys | every error type has a legitimate twin, so the document inventory never reveals what was planted |
|
|
73
|
+
| Truth | `world.errors` — hidden from the agent, used by the grader |
|
|
74
|
+
|
|
75
|
+
Two locales: `lang="en"` (default) and `lang="ru"`. Numbers, dates, accounts and errors are
|
|
76
|
+
identical across locales; only text differs.
|
|
77
|
+
|
|
78
|
+
## Error taxonomy
|
|
79
|
+
|
|
80
|
+
**Document-backed** (the evidence is in a document the agent can read):
|
|
81
|
+
|
|
82
|
+
| kind | what went wrong | correct fix |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `unrecorded_invoice` | subcontractor act never posted | Dr 5000 / Cr 2000 |
|
|
85
|
+
| `duplicate_invoice` | one invoice posted twice under two refs | Dr 2000 / Cr 6300 |
|
|
86
|
+
| `capex_expensed` | fixed asset above the policy limit expensed | Dr 1500 / Cr 6900 |
|
|
87
|
+
| `prepaid_not_deferred` | 12-month licence expensed in full | Dr 1300 / Cr 6500 |
|
|
88
|
+
| `revenue_cutoff` | customer prepayment for next year booked as revenue | Dr 4000 / Cr 2400 |
|
|
89
|
+
| `unrecorded_bank_fee` | fee on the bank statement, not in the books | Dr 6900 / Cr 1000 |
|
|
90
|
+
| `misclassified_expense` | warehouse rent booked to professional services | Dr 6100 / Cr 6300 |
|
|
91
|
+
| `intercompany_mismatch` | subsidiary's services never recorded | Dr 6900 / Cr 2500 |
|
|
92
|
+
| `fx_revaluation` | open EUR payable not revalued at period-end rate | Dr 6700 / Cr 2000 |
|
|
93
|
+
| `depreciation_prorata` | new asset in service mid-month, no depreciation (day-prorated) | Dr 6400 / Cr 1590 |
|
|
94
|
+
| `service_span_cutoff` | 60-day contract straddling period end, expensed in full (day-prorated) | Dr 1300 / Cr 6300 |
|
|
95
|
+
|
|
96
|
+
**Absence-only** (no document points at them — the error exists only as a gap in a series,
|
|
97
|
+
and it is hidden in a **random month of the year**, not the closing month):
|
|
98
|
+
|
|
99
|
+
| kind | what went wrong | correct fix |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `missing_recurring` | one month's security-services accrual missing | Dr 6600 / Cr 2100 |
|
|
102
|
+
| `payroll_cutoff` | one month's end-of-month payroll accrual missing | Dr 6000 / Cr 2200 |
|
|
103
|
+
| `depreciation_stopped` | one month's depreciation missing | Dr 6400 / Cr 1590 |
|
|
104
|
+
| `prepaid_amort_stopped` | one month's insurance amortisation missing | Dr 6800 / Cr 1300 |
|
|
105
|
+
| `accrual_not_released` | prior-month accrual not released at payment; expense double-counted | Dr 2200 / Cr 6000 |
|
|
106
|
+
| `inventory_receipt_gap` | goods received never posted although COGS was | Dr 1200 / Cr 2000 |
|
|
107
|
+
|
|
108
|
+
`hard` mode samples 6–9 kinds from all seventeen. `absence` mode samples 4–6 from the six
|
|
109
|
+
absence-only kinds. Amounts are seeded so that no two planted errors in one world share
|
|
110
|
+
the same account pair and amount.
|
|
111
|
+
|
|
112
|
+
## Grading
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
grade(world, session) -> {
|
|
116
|
+
"passed": bool, # every error found (exact or partial), TB balanced, nothing spurious
|
|
117
|
+
"score": float, # (exact + 0.5·partial) / total − 0.1·spurious, floored at 0
|
|
118
|
+
"found": [...], # exact account pair and amount
|
|
119
|
+
"partial": [...], # right amount, economically equivalent account (e.g. 6300 for 6900)
|
|
120
|
+
"missed": [...],
|
|
121
|
+
"spurious_entries": [...], # adjustments matching no planted error
|
|
122
|
+
"tb_balanced": bool,
|
|
123
|
+
"adjustments_posted": int,
|
|
124
|
+
"tool_calls": int,
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
An adjustment can satisfy at most one planted error. Equivalence groups are small and
|
|
129
|
+
explicit (`grader.EQUIV`); a defensible account choice earns half credit, never a penalty.
|
|
130
|
+
|
|
131
|
+
## Agent tools
|
|
132
|
+
|
|
133
|
+
`chart_of_accounts`, `trial_balance`, `journal`, `account_detail`, `list_documents`,
|
|
134
|
+
`read_document`, `pnl`, `post_entry`. Nothing else. The agent never sees `world.errors`.
|
|
135
|
+
|
|
136
|
+
## Command line
|
|
137
|
+
|
|
138
|
+
The CLI keeps truth in process memory only: the world is rebuilt from the seed on every
|
|
139
|
+
call and just the agent's postings are persisted, so an agent driving the CLI cannot read
|
|
140
|
+
the answer key from disk.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
closebench --session demo --start 7 --lang en
|
|
144
|
+
closebench --session demo --tool trial_balance
|
|
145
|
+
closebench --session demo --tool read_document --args '{"doc_id": "POL-01"}'
|
|
146
|
+
closebench --session demo --tool post_entry --args '{"date":"2026-11-30","memo":"...","lines":[...]}'
|
|
147
|
+
closebench --session demo --finish # grades and seals the session
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Seeds ≥ 1000 select `absence` mode. Sessions are sealed after `--finish`.
|
|
151
|
+
Session state lives in `./closebench_runs/` (override with `CLOSEBENCH_RUNS`).
|
|
152
|
+
|
|
153
|
+
## Running a model
|
|
154
|
+
|
|
155
|
+
`closebench.runner` contains a minimal tool-calling loop with Anthropic and OpenAI
|
|
156
|
+
backends (API key from the environment). It is deliberately small; wire in your own agent.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python -m closebench.runner --seeds 1,2,3,4,5 --backend anthropic --model claude-sonnet-5
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## What to expect
|
|
163
|
+
|
|
164
|
+
This is a **regression suite, not a hard benchmark.** In our runs a frontier model with a
|
|
165
|
+
neutral prompt closed `hard` worlds with 83 % pass rate and `absence` worlds with 58 %
|
|
166
|
+
(92 % if you accept the agent's refusal to book an undocumented goods receipt). Distractors
|
|
167
|
+
caught nobody. Use the score to catch regressions in your own agent, not to rank models.
|
|
168
|
+
|
|
169
|
+
The one thing models still get wrong: two errors that cancel in the balances (e.g. a
|
|
170
|
+
missing accrual and an unreleased accrual of the same amount). They are still two errors.
|
|
171
|
+
|
|
172
|
+
## Install
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip install closebench
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Python ≥ 3.10, no dependencies. Tests: `pip install pytest && python -m pytest -q`.
|
|
179
|
+
|
|
180
|
+
## Licence
|
|
181
|
+
|
|
182
|
+
MIT.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# closebench
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pahrya-arch/Closebench/actions/workflows/tests.yml)
|
|
4
|
+
|
|
5
|
+
Deterministic, seeded double-entry ledgers with planted month-end closing errors and a
|
|
6
|
+
programmatic grader. Zero dependencies. Built for testing bookkeeping agents.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from closebench.world import World
|
|
10
|
+
from closebench.tools import Session
|
|
11
|
+
from closebench.grader import grade
|
|
12
|
+
|
|
13
|
+
world = World(seed=7).build() # same seed → byte-identical books, every time
|
|
14
|
+
agent = Session(world) # the only surface an agent ever sees
|
|
15
|
+
|
|
16
|
+
print(agent.trial_balance()) # ...agent reads the books, documents, policy...
|
|
17
|
+
agent.post_entry("2026-11-30", "capitalise HW-7781", [
|
|
18
|
+
{"account": "1500", "debit": "175000.00"},
|
|
19
|
+
{"account": "6900", "credit": "175000.00"},
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
print(grade(world, agent))
|
|
23
|
+
# {'passed': False, 'score': 0.143, 'found': ['E3'], 'partial': [], 'missed': [...],
|
|
24
|
+
# 'spurious_entries': [], 'tb_balanced': True, ...}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why this exists
|
|
28
|
+
|
|
29
|
+
If you are building an agent that closes the books, you need books to close — with
|
|
30
|
+
errors you planted yourself, so you know the answer. Real ERP exports are messy, slow,
|
|
31
|
+
legally encumbered and never reproducible. LLM-judged rubrics drift.
|
|
32
|
+
|
|
33
|
+
`closebench` gives you a company: eleven months of operations, a chart of accounts,
|
|
34
|
+
supporting documents, an accounting policy, and a fixed set of closing errors chosen by
|
|
35
|
+
the seed. The grader checks the agent's adjusting entries against the planted errors by
|
|
36
|
+
account and amount — no rubric, no judge model, no human in the loop.
|
|
37
|
+
|
|
38
|
+
Use it as a regression suite: run your agent over 50 seeds on every change and watch
|
|
39
|
+
`pass_rate` and `mean_score`.
|
|
40
|
+
|
|
41
|
+
## What a world contains
|
|
42
|
+
|
|
43
|
+
| | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Ledger | ~140 balanced entries across 11 months, 26-account chart, cash and inventory never negative |
|
|
46
|
+
| Documents | ~25: vendor invoices, contracts, bank statement, intercompany email, FX rate certificate, accounting policy |
|
|
47
|
+
| Planted errors | 6–9 per world in `hard` mode, 4–6 in `absence` mode |
|
|
48
|
+
| Distractors | legitimate transactions that look like errors — correcting them costs points |
|
|
49
|
+
| Decoys | every error type has a legitimate twin, so the document inventory never reveals what was planted |
|
|
50
|
+
| Truth | `world.errors` — hidden from the agent, used by the grader |
|
|
51
|
+
|
|
52
|
+
Two locales: `lang="en"` (default) and `lang="ru"`. Numbers, dates, accounts and errors are
|
|
53
|
+
identical across locales; only text differs.
|
|
54
|
+
|
|
55
|
+
## Error taxonomy
|
|
56
|
+
|
|
57
|
+
**Document-backed** (the evidence is in a document the agent can read):
|
|
58
|
+
|
|
59
|
+
| kind | what went wrong | correct fix |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `unrecorded_invoice` | subcontractor act never posted | Dr 5000 / Cr 2000 |
|
|
62
|
+
| `duplicate_invoice` | one invoice posted twice under two refs | Dr 2000 / Cr 6300 |
|
|
63
|
+
| `capex_expensed` | fixed asset above the policy limit expensed | Dr 1500 / Cr 6900 |
|
|
64
|
+
| `prepaid_not_deferred` | 12-month licence expensed in full | Dr 1300 / Cr 6500 |
|
|
65
|
+
| `revenue_cutoff` | customer prepayment for next year booked as revenue | Dr 4000 / Cr 2400 |
|
|
66
|
+
| `unrecorded_bank_fee` | fee on the bank statement, not in the books | Dr 6900 / Cr 1000 |
|
|
67
|
+
| `misclassified_expense` | warehouse rent booked to professional services | Dr 6100 / Cr 6300 |
|
|
68
|
+
| `intercompany_mismatch` | subsidiary's services never recorded | Dr 6900 / Cr 2500 |
|
|
69
|
+
| `fx_revaluation` | open EUR payable not revalued at period-end rate | Dr 6700 / Cr 2000 |
|
|
70
|
+
| `depreciation_prorata` | new asset in service mid-month, no depreciation (day-prorated) | Dr 6400 / Cr 1590 |
|
|
71
|
+
| `service_span_cutoff` | 60-day contract straddling period end, expensed in full (day-prorated) | Dr 1300 / Cr 6300 |
|
|
72
|
+
|
|
73
|
+
**Absence-only** (no document points at them — the error exists only as a gap in a series,
|
|
74
|
+
and it is hidden in a **random month of the year**, not the closing month):
|
|
75
|
+
|
|
76
|
+
| kind | what went wrong | correct fix |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `missing_recurring` | one month's security-services accrual missing | Dr 6600 / Cr 2100 |
|
|
79
|
+
| `payroll_cutoff` | one month's end-of-month payroll accrual missing | Dr 6000 / Cr 2200 |
|
|
80
|
+
| `depreciation_stopped` | one month's depreciation missing | Dr 6400 / Cr 1590 |
|
|
81
|
+
| `prepaid_amort_stopped` | one month's insurance amortisation missing | Dr 6800 / Cr 1300 |
|
|
82
|
+
| `accrual_not_released` | prior-month accrual not released at payment; expense double-counted | Dr 2200 / Cr 6000 |
|
|
83
|
+
| `inventory_receipt_gap` | goods received never posted although COGS was | Dr 1200 / Cr 2000 |
|
|
84
|
+
|
|
85
|
+
`hard` mode samples 6–9 kinds from all seventeen. `absence` mode samples 4–6 from the six
|
|
86
|
+
absence-only kinds. Amounts are seeded so that no two planted errors in one world share
|
|
87
|
+
the same account pair and amount.
|
|
88
|
+
|
|
89
|
+
## Grading
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
grade(world, session) -> {
|
|
93
|
+
"passed": bool, # every error found (exact or partial), TB balanced, nothing spurious
|
|
94
|
+
"score": float, # (exact + 0.5·partial) / total − 0.1·spurious, floored at 0
|
|
95
|
+
"found": [...], # exact account pair and amount
|
|
96
|
+
"partial": [...], # right amount, economically equivalent account (e.g. 6300 for 6900)
|
|
97
|
+
"missed": [...],
|
|
98
|
+
"spurious_entries": [...], # adjustments matching no planted error
|
|
99
|
+
"tb_balanced": bool,
|
|
100
|
+
"adjustments_posted": int,
|
|
101
|
+
"tool_calls": int,
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
An adjustment can satisfy at most one planted error. Equivalence groups are small and
|
|
106
|
+
explicit (`grader.EQUIV`); a defensible account choice earns half credit, never a penalty.
|
|
107
|
+
|
|
108
|
+
## Agent tools
|
|
109
|
+
|
|
110
|
+
`chart_of_accounts`, `trial_balance`, `journal`, `account_detail`, `list_documents`,
|
|
111
|
+
`read_document`, `pnl`, `post_entry`. Nothing else. The agent never sees `world.errors`.
|
|
112
|
+
|
|
113
|
+
## Command line
|
|
114
|
+
|
|
115
|
+
The CLI keeps truth in process memory only: the world is rebuilt from the seed on every
|
|
116
|
+
call and just the agent's postings are persisted, so an agent driving the CLI cannot read
|
|
117
|
+
the answer key from disk.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
closebench --session demo --start 7 --lang en
|
|
121
|
+
closebench --session demo --tool trial_balance
|
|
122
|
+
closebench --session demo --tool read_document --args '{"doc_id": "POL-01"}'
|
|
123
|
+
closebench --session demo --tool post_entry --args '{"date":"2026-11-30","memo":"...","lines":[...]}'
|
|
124
|
+
closebench --session demo --finish # grades and seals the session
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Seeds ≥ 1000 select `absence` mode. Sessions are sealed after `--finish`.
|
|
128
|
+
Session state lives in `./closebench_runs/` (override with `CLOSEBENCH_RUNS`).
|
|
129
|
+
|
|
130
|
+
## Running a model
|
|
131
|
+
|
|
132
|
+
`closebench.runner` contains a minimal tool-calling loop with Anthropic and OpenAI
|
|
133
|
+
backends (API key from the environment). It is deliberately small; wire in your own agent.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python -m closebench.runner --seeds 1,2,3,4,5 --backend anthropic --model claude-sonnet-5
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## What to expect
|
|
140
|
+
|
|
141
|
+
This is a **regression suite, not a hard benchmark.** In our runs a frontier model with a
|
|
142
|
+
neutral prompt closed `hard` worlds with 83 % pass rate and `absence` worlds with 58 %
|
|
143
|
+
(92 % if you accept the agent's refusal to book an undocumented goods receipt). Distractors
|
|
144
|
+
caught nobody. Use the score to catch regressions in your own agent, not to rank models.
|
|
145
|
+
|
|
146
|
+
The one thing models still get wrong: two errors that cancel in the balances (e.g. a
|
|
147
|
+
missing accrual and an unreleased accrual of the same amount). They are still two errors.
|
|
148
|
+
|
|
149
|
+
## Install
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install closebench
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Python ≥ 3.10, no dependencies. Tests: `pip install pytest && python -m pytest -q`.
|
|
156
|
+
|
|
157
|
+
## Licence
|
|
158
|
+
|
|
159
|
+
MIT.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""closebench — deterministic, seeded double-entry ledgers with planted closing errors
|
|
2
|
+
and a programmatic grader. Test fixtures for bookkeeping agents.
|
|
3
|
+
|
|
4
|
+
from closebench.world import World
|
|
5
|
+
from closebench.tools import Session
|
|
6
|
+
from closebench.grader import grade
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Интерфейс агента: состояние сессии на диске, истина только в памяти процесса.
|
|
2
|
+
Агент вызывает инструменты и не имеет доступа к посаженным ошибкам."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse, json, os, sys
|
|
5
|
+
from closebench.world import World
|
|
6
|
+
from closebench.tools import Session, tool_spec
|
|
7
|
+
from closebench.grader import grade
|
|
8
|
+
from closebench.strings import T, LANGS
|
|
9
|
+
|
|
10
|
+
RUNS = os.environ.get("CLOSEBENCH_RUNS", os.path.join(os.getcwd(), "closebench_runs"))
|
|
11
|
+
|
|
12
|
+
def state_path(sid): return os.path.join(RUNS, f"session_{sid}.json")
|
|
13
|
+
|
|
14
|
+
def mode_for(seed: int) -> str:
|
|
15
|
+
"""Режим кодируется номером сида, а не флагом: флаг виден агенту в командной
|
|
16
|
+
строке и сам по себе является подсказкой о характере задачи."""
|
|
17
|
+
return "absence" if seed >= 1000 else "hard"
|
|
18
|
+
|
|
19
|
+
def load(sid):
|
|
20
|
+
p = state_path(sid)
|
|
21
|
+
if not os.path.exists(p): return None
|
|
22
|
+
return json.load(open(p))
|
|
23
|
+
|
|
24
|
+
def save(sid, st):
|
|
25
|
+
os.makedirs(RUNS, exist_ok=True)
|
|
26
|
+
json.dump(st, open(state_path(sid), "w"), ensure_ascii=False)
|
|
27
|
+
|
|
28
|
+
def rebuild(sid):
|
|
29
|
+
"""Восстанавливаем мир по seed и переигрываем проводки агента."""
|
|
30
|
+
st = load(sid)
|
|
31
|
+
if not st: return None, None, None
|
|
32
|
+
# Сессии, записанные до появления поля lang, были русскими.
|
|
33
|
+
w = World(seed=st["seed"], lang=st.get("lang", "ru"),
|
|
34
|
+
difficulty=st.get("mode", "hard")).build()
|
|
35
|
+
s = Session(w)
|
|
36
|
+
for a in st["posted"]:
|
|
37
|
+
s.post_entry(a["date"], a["memo"], a["lines"])
|
|
38
|
+
s.external_calls = st.get("calls", 0)
|
|
39
|
+
return st, w, s
|
|
40
|
+
|
|
41
|
+
def tools_text(lang: str) -> str:
|
|
42
|
+
return "\n".join(T(lang, "cli.tool_line", **t) for t in tool_spec(lang))
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
ap = argparse.ArgumentParser()
|
|
46
|
+
ap.add_argument("--session", required=True)
|
|
47
|
+
ap.add_argument("--start", type=int, help="seed of a new episode")
|
|
48
|
+
ap.add_argument("--lang", choices=LANGS, default="en",
|
|
49
|
+
help="world language for --start (default: en); stored in the session")
|
|
50
|
+
ap.add_argument("--tool")
|
|
51
|
+
ap.add_argument("--args", default="{}")
|
|
52
|
+
ap.add_argument("--finish", action="store_true")
|
|
53
|
+
a = ap.parse_args()
|
|
54
|
+
|
|
55
|
+
if a.start is not None:
|
|
56
|
+
mode, lang = mode_for(a.start), a.lang
|
|
57
|
+
w = World(seed=a.start, lang=lang, difficulty=mode).build()
|
|
58
|
+
save(a.session, {"seed": a.start, "mode": mode, "lang": lang, "posted": [], "calls": 0})
|
|
59
|
+
print(T(lang, "cli.started", start=w.period_start, end=w.period_end, name=w.name))
|
|
60
|
+
print(T(lang, "cli.counts", entries=len(w.ledger.entries), documents=len(w.documents)))
|
|
61
|
+
print(T(lang, "cli.task", end=w.period_end))
|
|
62
|
+
print(T(lang, "cli.no_extra"))
|
|
63
|
+
print(T(lang, "cli.tools", tools=tools_text(lang)))
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
st, w, s = rebuild(a.session)
|
|
67
|
+
if not st:
|
|
68
|
+
print(T(a.lang, "cli.session_not_found")); sys.exit(1)
|
|
69
|
+
lang = w.lang
|
|
70
|
+
|
|
71
|
+
if a.finish:
|
|
72
|
+
res = grade(w, s)
|
|
73
|
+
res["seed"] = st["seed"]
|
|
74
|
+
st["closed"] = True
|
|
75
|
+
save(a.session, st)
|
|
76
|
+
print(json.dumps(res, ensure_ascii=False, indent=1))
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if st.get("closed"):
|
|
80
|
+
print(T(lang, "cli.closed")); sys.exit(1)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
args = json.loads(a.args)
|
|
84
|
+
except json.JSONDecodeError as e:
|
|
85
|
+
print(T(lang, "cli.bad_json", e=e)); sys.exit(1)
|
|
86
|
+
|
|
87
|
+
fn = getattr(s, a.tool, None)
|
|
88
|
+
if not fn or a.tool.startswith("_"):
|
|
89
|
+
print(T(lang, "cli.no_tool", tool=a.tool)); sys.exit(1)
|
|
90
|
+
before = len(s.posted)
|
|
91
|
+
try:
|
|
92
|
+
out = fn(**args)
|
|
93
|
+
except TypeError as e:
|
|
94
|
+
print(T(lang, "cli.bad_params", e=e)); sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# Успешная проводка распознаётся по факту, а не по тексту ответа (текст зависит от языка).
|
|
97
|
+
if a.tool == "post_entry" and len(s.posted) > before:
|
|
98
|
+
st["posted"].append({"date": args["date"], "memo": args["memo"], "lines": args["lines"]})
|
|
99
|
+
st["calls"] += 1
|
|
100
|
+
save(a.session, st)
|
|
101
|
+
print(out)
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Программная награда. Никаких рубрик и оценок человеком."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from closebench.ledger import D
|
|
5
|
+
|
|
6
|
+
def _adj_entries(world, session):
|
|
7
|
+
return [e for e in world.ledger.entries if e.eid in set(session.posted)]
|
|
8
|
+
|
|
9
|
+
# Экономически эквивалентные счета: ошибка в выборе внутри группы — не ошибка по сути.
|
|
10
|
+
EQUIV = [
|
|
11
|
+
{"6900", "6300"}, # прочие расходы / профуслуги
|
|
12
|
+
{"2500", "2000"}, # межкомпанийные / кредиторка
|
|
13
|
+
{"2400", "2000"}, # доходы будущих периодов / кредиторка
|
|
14
|
+
{"1300", "1200"}, # расходы будущих периодов / запасы
|
|
15
|
+
{"6700", "6900"}, # курсовые разницы / прочие расходы
|
|
16
|
+
{"2100", "2000"}, # начисленные обязательства / кредиторка
|
|
17
|
+
{"2200", "2100"}, # зарплата к выплате / начисленные обязательства
|
|
18
|
+
{"6600", "6900"}, # связь и охрана / прочие расходы
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def _same(a: str, b: str) -> bool:
|
|
22
|
+
if a == b:
|
|
23
|
+
return True
|
|
24
|
+
return any(a in grp and b in grp for grp in EQUIV)
|
|
25
|
+
|
|
26
|
+
def _matches(e, want_dr, want_cr, amount, strict=True) -> bool:
|
|
27
|
+
amt = D(amount)
|
|
28
|
+
eq = (lambda a, b: a == b) if strict else _same
|
|
29
|
+
dr = [l for l in e.lines if l.debit == amt and eq(l.account, want_dr)]
|
|
30
|
+
cr = [l for l in e.lines if l.credit == amt and eq(l.account, want_cr)]
|
|
31
|
+
return bool(dr and cr)
|
|
32
|
+
|
|
33
|
+
def grade(world, session) -> dict:
|
|
34
|
+
adjs = _adj_entries(world, session)
|
|
35
|
+
found, partial, missed = [], [], []
|
|
36
|
+
matched_eids = set()
|
|
37
|
+
for err in world.errors:
|
|
38
|
+
fix = err.fix
|
|
39
|
+
exact = near = None
|
|
40
|
+
for e in adjs:
|
|
41
|
+
if e.eid in matched_eids: # одна проводка закрывает максимум одну ошибку
|
|
42
|
+
continue
|
|
43
|
+
if not (fix["period"][0] <= e.date <= fix["period"][1]):
|
|
44
|
+
continue
|
|
45
|
+
if fix["type"] not in ("require_entry", "require_reversal"):
|
|
46
|
+
continue
|
|
47
|
+
if _matches(e, fix["debit_account"], fix["credit_account"], fix["amount"], strict=True):
|
|
48
|
+
exact = e.eid
|
|
49
|
+
break
|
|
50
|
+
if near is None and _matches(e, fix["debit_account"], fix["credit_account"],
|
|
51
|
+
fix["amount"], strict=False):
|
|
52
|
+
near = e.eid
|
|
53
|
+
if exact:
|
|
54
|
+
found.append(err.err_id); matched_eids.add(exact)
|
|
55
|
+
elif near:
|
|
56
|
+
partial.append(err.err_id); matched_eids.add(near)
|
|
57
|
+
else:
|
|
58
|
+
missed.append(err.err_id)
|
|
59
|
+
|
|
60
|
+
d, c = world.ledger.tb_totals(world.period_end)
|
|
61
|
+
tb_balanced = d == c
|
|
62
|
+
|
|
63
|
+
# «ничего лишнего»: корректировки, не сопоставленные ни одной ошибке
|
|
64
|
+
spurious = [e.eid for e in adjs if e.eid not in matched_eids]
|
|
65
|
+
|
|
66
|
+
total = len(world.errors)
|
|
67
|
+
score = (len(found) + 0.5 * len(partial)) / total if total else 0.0
|
|
68
|
+
penalty = 0.1 * len(spurious)
|
|
69
|
+
passed = (len(found) + len(partial) == total) and tb_balanced and not spurious
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"passed": passed,
|
|
73
|
+
"score": round(max(0.0, score - penalty), 3),
|
|
74
|
+
"found": found, "partial": partial, "missed": missed,
|
|
75
|
+
"spurious_entries": spurious,
|
|
76
|
+
"tb_balanced": tb_balanced,
|
|
77
|
+
"adjustments_posted": len(adjs),
|
|
78
|
+
"tool_calls": getattr(session, "external_calls", len(session.calls)),
|
|
79
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Детерминированная двойная запись. Ядро мира: без внешних зависимостей."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
from closebench.strings import T
|
|
7
|
+
|
|
8
|
+
def D(x) -> Decimal:
|
|
9
|
+
return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
10
|
+
|
|
11
|
+
ASSET, LIAB, EQUITY, INCOME, EXPENSE = "asset", "liability", "equity", "income", "expense"
|
|
12
|
+
DEBIT_NORMAL = {ASSET, EXPENSE}
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Account:
|
|
16
|
+
code: str
|
|
17
|
+
name: str
|
|
18
|
+
type: str
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Line:
|
|
22
|
+
account: str
|
|
23
|
+
debit: Decimal = Decimal("0.00")
|
|
24
|
+
credit: Decimal = Decimal("0.00")
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Entry:
|
|
28
|
+
eid: str
|
|
29
|
+
date: str # YYYY-MM-DD
|
|
30
|
+
memo: str
|
|
31
|
+
lines: list[Line]
|
|
32
|
+
source: str = "manual" # manual | ap | ar | bank | accrual | payroll
|
|
33
|
+
ref: str = "" # номер документа
|
|
34
|
+
tags: dict = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def total_debit(self) -> Decimal:
|
|
37
|
+
return sum((l.debit for l in self.lines), Decimal("0.00"))
|
|
38
|
+
|
|
39
|
+
def total_credit(self) -> Decimal:
|
|
40
|
+
return sum((l.credit for l in self.lines), Decimal("0.00"))
|
|
41
|
+
|
|
42
|
+
def is_balanced(self) -> bool:
|
|
43
|
+
return self.total_debit() == self.total_credit()
|
|
44
|
+
|
|
45
|
+
class Ledger:
|
|
46
|
+
def __init__(self, accounts: Iterable[Account], lang: str = "en"):
|
|
47
|
+
self.accounts: dict[str, Account] = {a.code: a for a in accounts}
|
|
48
|
+
self.entries: list[Entry] = []
|
|
49
|
+
self.lang = lang # язык сообщений об ошибках проводки
|
|
50
|
+
self._seq = 0
|
|
51
|
+
|
|
52
|
+
def next_id(self, prefix="JE") -> str:
|
|
53
|
+
self._seq += 1
|
|
54
|
+
return f"{prefix}{self._seq:05d}"
|
|
55
|
+
|
|
56
|
+
def post(self, entry: Entry) -> str:
|
|
57
|
+
if not entry.lines:
|
|
58
|
+
raise ValueError(T(self.lang, "ledger.no_lines"))
|
|
59
|
+
for l in entry.lines:
|
|
60
|
+
if l.account not in self.accounts:
|
|
61
|
+
raise ValueError(T(self.lang, "ledger.unknown_account", account=l.account))
|
|
62
|
+
if l.debit < 0 or l.credit < 0:
|
|
63
|
+
raise ValueError(T(self.lang, "ledger.negative"))
|
|
64
|
+
if l.debit > 0 and l.credit > 0:
|
|
65
|
+
raise ValueError(T(self.lang, "ledger.both_sides"))
|
|
66
|
+
if not entry.is_balanced():
|
|
67
|
+
raise ValueError(T(self.lang, "ledger.unbalanced",
|
|
68
|
+
d=entry.total_debit(), c=entry.total_credit()))
|
|
69
|
+
self.entries.append(entry)
|
|
70
|
+
return entry.eid
|
|
71
|
+
|
|
72
|
+
def in_period(self, start: str, end: str) -> list[Entry]:
|
|
73
|
+
return [e for e in self.entries if start <= e.date <= end]
|
|
74
|
+
|
|
75
|
+
def balance(self, code: str, upto: str | None = None) -> Decimal:
|
|
76
|
+
acc = self.accounts[code]
|
|
77
|
+
bal = Decimal("0.00")
|
|
78
|
+
for e in self.entries:
|
|
79
|
+
if upto and e.date > upto:
|
|
80
|
+
continue
|
|
81
|
+
for l in e.lines:
|
|
82
|
+
if l.account == code:
|
|
83
|
+
bal += l.debit - l.credit
|
|
84
|
+
return bal if acc.type in DEBIT_NORMAL else -bal
|
|
85
|
+
|
|
86
|
+
def trial_balance(self, upto: str) -> list[dict]:
|
|
87
|
+
rows = []
|
|
88
|
+
for code, acc in sorted(self.accounts.items()):
|
|
89
|
+
raw = Decimal("0.00")
|
|
90
|
+
for e in self.entries:
|
|
91
|
+
if e.date > upto:
|
|
92
|
+
continue
|
|
93
|
+
for l in e.lines:
|
|
94
|
+
if l.account == code:
|
|
95
|
+
raw += l.debit - l.credit
|
|
96
|
+
if raw == 0:
|
|
97
|
+
continue
|
|
98
|
+
rows.append({"code": code, "name": acc.name, "type": acc.type,
|
|
99
|
+
"debit": raw if raw > 0 else Decimal("0.00"),
|
|
100
|
+
"credit": -raw if raw < 0 else Decimal("0.00")})
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
def tb_totals(self, upto: str) -> tuple[Decimal, Decimal]:
|
|
104
|
+
rows = self.trial_balance(upto)
|
|
105
|
+
return (sum((r["debit"] for r in rows), Decimal("0.00")),
|
|
106
|
+
sum((r["credit"] for r in rows), Decimal("0.00")))
|
|
107
|
+
|
|
108
|
+
def pnl(self, start: str, end: str) -> dict:
|
|
109
|
+
income = expense = Decimal("0.00")
|
|
110
|
+
for e in self.in_period(start, end):
|
|
111
|
+
for l in e.lines:
|
|
112
|
+
t = self.accounts[l.account].type
|
|
113
|
+
if t == INCOME:
|
|
114
|
+
income += l.credit - l.debit
|
|
115
|
+
elif t == EXPENSE:
|
|
116
|
+
expense += l.debit - l.credit
|
|
117
|
+
return {"income": income, "expense": expense, "net": income - expense}
|