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.
@@ -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
+ [![tests](https://github.com/pahrya-arch/Closebench/actions/workflows/tests.yml/badge.svg)](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
+ [![tests](https://github.com/pahrya-arch/Closebench/actions/workflows/tests.yml/badge.svg)](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}