worthune 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.
- worthune-0.1.0/.gitignore +5 -0
- worthune-0.1.0/PKG-INFO +73 -0
- worthune-0.1.0/README.md +54 -0
- worthune-0.1.0/pyproject.toml +40 -0
- worthune-0.1.0/src/worthune/__init__.py +206 -0
- worthune-0.1.0/tests/fixture-relocation.json +1 -0
- worthune-0.1.0/tests/test_sdk.py +71 -0
worthune-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: worthune
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official SDK for the Worthune Model API — 27 dual-implementation-verified financial calculation models with published specs and IRS/SSA-sourced constants. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://worthune.com
|
|
6
|
+
Project-URL: Documentation, https://worthune.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/CapsteraSupport/worthune-sdk
|
|
8
|
+
Author: Worthune
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: api-client,calculator,evals,finance,financial-calculations,financial-planning,fintech,mortgage,retirement,verified
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# worthune
|
|
21
|
+
|
|
22
|
+
Zero-dependency SDK for the [Worthune Model API](https://worthune.com/docs) —
|
|
23
|
+
27 financial calculation models you can cite, audit, and trust. No API keys.
|
|
24
|
+
Free with attribution.
|
|
25
|
+
|
|
26
|
+
Every model has a **published spec**, an **independent second implementation
|
|
27
|
+
that must agree** with the first on 250 fuzzed cases before anything ships,
|
|
28
|
+
and **IRS/SSA constants traced to primary sources**. Model changes are never
|
|
29
|
+
silent: responses pin `specVersion`, and the
|
|
30
|
+
[changelog is public](https://worthune.com/models/changelog).
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install worthune
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from worthune import Worthune, verify_record
|
|
38
|
+
|
|
39
|
+
client = Worthune()
|
|
40
|
+
|
|
41
|
+
result = client.run("relocation", {
|
|
42
|
+
"currentSalary": 95000, "newSalary": 108000,
|
|
43
|
+
"currentMonthlyExpenses": 4200, "newMonthlyExpenses": 4900,
|
|
44
|
+
"movingCosts": 6000, "currentSavings": 40000,
|
|
45
|
+
"annualReturn": 0.07, "yearsHorizon": 10,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
result["outputs"]["breakEvenMonths"] # 16
|
|
49
|
+
result["specVersion"] # pinned contract version
|
|
50
|
+
result["facts"] # IRS/SSA constants used, with sources
|
|
51
|
+
result["sentinels"] # special values, explained
|
|
52
|
+
verify_record(result) # True — audit fingerprint checks out
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
- `client.list_models()` — catalog with spec versions
|
|
58
|
+
- `client.get_contract(model)` — machine-readable inputs/domains/sentinels
|
|
59
|
+
- `client.get_spec(model)` — the full published spec (markdown)
|
|
60
|
+
- `client.run(model, inputs)` — run; validation failures return `{"ok": False, "errors": [...]}` instead of raising
|
|
61
|
+
- `client.get_eval_dataset(model)` — 250 verified input/expected-output pairs, the exact cases Worthune's dual-implementation CI verifies (ground truth for financial AI testing)
|
|
62
|
+
- `client.get_facts()` — the sourced IRS/SSA constants registry
|
|
63
|
+
- `verify_record(response)` — recompute the SHA-256 decision record to prove where numbers came from (byte-exact ECMAScript-compatible canonical JSON)
|
|
64
|
+
|
|
65
|
+
Retirement, FIRE, rent vs. buy, refinancing, equity comp (RSUs), debt payoff,
|
|
66
|
+
Social Security timing, 529 plans, estate planning, and more — the
|
|
67
|
+
[full catalog](https://worthune.com/models) lists all 27.
|
|
68
|
+
|
|
69
|
+
Fair use: 5,000 runs/month per app (a guideline, not a meter) —
|
|
70
|
+
[the free tier, in writing](https://worthune.com/pricing).
|
|
71
|
+
|
|
72
|
+
MIT licensed. The models, specs, and verification harness live behind the API
|
|
73
|
+
at [worthune.com](https://worthune.com).
|
worthune-0.1.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# worthune
|
|
2
|
+
|
|
3
|
+
Zero-dependency SDK for the [Worthune Model API](https://worthune.com/docs) —
|
|
4
|
+
27 financial calculation models you can cite, audit, and trust. No API keys.
|
|
5
|
+
Free with attribution.
|
|
6
|
+
|
|
7
|
+
Every model has a **published spec**, an **independent second implementation
|
|
8
|
+
that must agree** with the first on 250 fuzzed cases before anything ships,
|
|
9
|
+
and **IRS/SSA constants traced to primary sources**. Model changes are never
|
|
10
|
+
silent: responses pin `specVersion`, and the
|
|
11
|
+
[changelog is public](https://worthune.com/models/changelog).
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install worthune
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from worthune import Worthune, verify_record
|
|
19
|
+
|
|
20
|
+
client = Worthune()
|
|
21
|
+
|
|
22
|
+
result = client.run("relocation", {
|
|
23
|
+
"currentSalary": 95000, "newSalary": 108000,
|
|
24
|
+
"currentMonthlyExpenses": 4200, "newMonthlyExpenses": 4900,
|
|
25
|
+
"movingCosts": 6000, "currentSavings": 40000,
|
|
26
|
+
"annualReturn": 0.07, "yearsHorizon": 10,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
result["outputs"]["breakEvenMonths"] # 16
|
|
30
|
+
result["specVersion"] # pinned contract version
|
|
31
|
+
result["facts"] # IRS/SSA constants used, with sources
|
|
32
|
+
result["sentinels"] # special values, explained
|
|
33
|
+
verify_record(result) # True — audit fingerprint checks out
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
- `client.list_models()` — catalog with spec versions
|
|
39
|
+
- `client.get_contract(model)` — machine-readable inputs/domains/sentinels
|
|
40
|
+
- `client.get_spec(model)` — the full published spec (markdown)
|
|
41
|
+
- `client.run(model, inputs)` — run; validation failures return `{"ok": False, "errors": [...]}` instead of raising
|
|
42
|
+
- `client.get_eval_dataset(model)` — 250 verified input/expected-output pairs, the exact cases Worthune's dual-implementation CI verifies (ground truth for financial AI testing)
|
|
43
|
+
- `client.get_facts()` — the sourced IRS/SSA constants registry
|
|
44
|
+
- `verify_record(response)` — recompute the SHA-256 decision record to prove where numbers came from (byte-exact ECMAScript-compatible canonical JSON)
|
|
45
|
+
|
|
46
|
+
Retirement, FIRE, rent vs. buy, refinancing, equity comp (RSUs), debt payoff,
|
|
47
|
+
Social Security timing, 529 plans, estate planning, and more — the
|
|
48
|
+
[full catalog](https://worthune.com/models) lists all 27.
|
|
49
|
+
|
|
50
|
+
Fair use: 5,000 runs/month per app (a guideline, not a meter) —
|
|
51
|
+
[the free tier, in writing](https://worthune.com/pricing).
|
|
52
|
+
|
|
53
|
+
MIT licensed. The models, specs, and verification harness live behind the API
|
|
54
|
+
at [worthune.com](https://worthune.com).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "worthune"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official SDK for the Worthune Model API — 27 dual-implementation-verified financial calculation models with published specs and IRS/SSA-sourced constants. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Worthune" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"finance",
|
|
15
|
+
"financial-calculations",
|
|
16
|
+
"fintech",
|
|
17
|
+
"retirement",
|
|
18
|
+
"mortgage",
|
|
19
|
+
"verified",
|
|
20
|
+
"calculator",
|
|
21
|
+
"api-client",
|
|
22
|
+
"financial-planning",
|
|
23
|
+
"evals",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Topic :: Office/Business :: Financial",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://worthune.com"
|
|
36
|
+
Documentation = "https://worthune.com/docs"
|
|
37
|
+
Repository = "https://github.com/CapsteraSupport/worthune-sdk"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/worthune"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Worthune SDK — thin, zero-dependency client for the Worthune Model API.
|
|
2
|
+
|
|
3
|
+
27 financial calculation models, each with a published spec and an
|
|
4
|
+
independent second implementation that must agree with the first before
|
|
5
|
+
anything ships. Docs: https://worthune.com/docs
|
|
6
|
+
|
|
7
|
+
The SDK is a client only — the models, specs, and verification harness live
|
|
8
|
+
behind the API. Free with attribution: https://worthune.com/pricing
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Worthune",
|
|
23
|
+
"WorthuneError",
|
|
24
|
+
"canonical_json",
|
|
25
|
+
"verify_record",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_DEFAULT_BASE_URL = "https://worthune.com"
|
|
29
|
+
_USER_AGENT = f"worthune-python/{__version__}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WorthuneError(Exception):
|
|
33
|
+
"""Raised for transport failures and non-validation HTTP errors."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, status: int | None = None, body: Any = None):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status = status
|
|
38
|
+
self.body = body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Worthune:
|
|
42
|
+
"""Client for the Worthune Model API.
|
|
43
|
+
|
|
44
|
+
>>> client = Worthune()
|
|
45
|
+
>>> result = client.run("relocation", {"currentSalary": 95000, ...})
|
|
46
|
+
>>> result["outputs"]["breakEvenMonths"]
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_url: str = _DEFAULT_BASE_URL, timeout: float = 30.0):
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
|
|
53
|
+
# ── transport ────────────────────────────────────────────────────────────
|
|
54
|
+
def _request(self, path: str, payload: dict[str, Any] | None = None) -> bytes:
|
|
55
|
+
url = f"{self.base_url}{path}"
|
|
56
|
+
data = None
|
|
57
|
+
headers = {"user-agent": _USER_AGENT, "accept": "*/*"}
|
|
58
|
+
if payload is not None:
|
|
59
|
+
data = json.dumps(payload).encode("utf-8")
|
|
60
|
+
headers["content-type"] = "application/json"
|
|
61
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
64
|
+
return res.read()
|
|
65
|
+
except urllib.error.HTTPError as err:
|
|
66
|
+
body_bytes = err.read()
|
|
67
|
+
# 400s carry the structured validation envelope callers want;
|
|
68
|
+
# everything else (404, 429 burst backstop, 5xx) raises.
|
|
69
|
+
if err.code == 400:
|
|
70
|
+
return body_bytes
|
|
71
|
+
body: Any = None
|
|
72
|
+
try:
|
|
73
|
+
body = json.loads(body_bytes)
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
raise WorthuneError(
|
|
77
|
+
f"{'POST' if payload is not None else 'GET'} {path} -> {err.code}",
|
|
78
|
+
status=err.code,
|
|
79
|
+
body=body,
|
|
80
|
+
) from err
|
|
81
|
+
except urllib.error.URLError as err:
|
|
82
|
+
raise WorthuneError(f"request to {url} failed: {err.reason}") from err
|
|
83
|
+
|
|
84
|
+
def _get_json(self, path: str) -> Any:
|
|
85
|
+
return json.loads(self._request(path))
|
|
86
|
+
|
|
87
|
+
# ── API surface ──────────────────────────────────────────────────────────
|
|
88
|
+
def list_models(self) -> list[dict[str, Any]]:
|
|
89
|
+
"""Catalog of all models with spec versions."""
|
|
90
|
+
return self._get_json("/api/v1/models")["models"]
|
|
91
|
+
|
|
92
|
+
def get_contract(self, model: str) -> dict[str, Any]:
|
|
93
|
+
"""Machine-readable contract: inputs, domains, sentinels, spec version."""
|
|
94
|
+
return self._get_json(f"/api/v1/models/{model}")
|
|
95
|
+
|
|
96
|
+
def get_spec(self, model: str) -> str:
|
|
97
|
+
"""The full published spec, as markdown."""
|
|
98
|
+
return self._request(f"/api/v1/models/{model}/spec").decode("utf-8")
|
|
99
|
+
|
|
100
|
+
def run(self, model: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
101
|
+
"""Run a model.
|
|
102
|
+
|
|
103
|
+
Returns the full envelope — outputs, sentinels, assumptions, cited
|
|
104
|
+
facts, and the decision record. Validation failures return
|
|
105
|
+
``{"ok": False, "errors": [...]}`` rather than raising; network and
|
|
106
|
+
server errors raise :class:`WorthuneError`.
|
|
107
|
+
"""
|
|
108
|
+
return json.loads(self._request(f"/api/v1/models/{model}", payload=inputs))
|
|
109
|
+
|
|
110
|
+
def list_evals(self) -> dict[str, Any]:
|
|
111
|
+
"""Index of verified eval datasets."""
|
|
112
|
+
return self._get_json("/api/v1/evals")
|
|
113
|
+
|
|
114
|
+
def get_eval_dataset(self, model: str) -> dict[str, Any]:
|
|
115
|
+
"""One eval dataset: ``{"meta": {...}, "cases": [...]}``.
|
|
116
|
+
|
|
117
|
+
250 deterministic input/expected-output pairs — the exact cases
|
|
118
|
+
Worthune's dual-implementation CI harness verifies.
|
|
119
|
+
"""
|
|
120
|
+
lines = self._request(f"/api/v1/evals/{model}").decode("utf-8").strip().split("\n")
|
|
121
|
+
return {
|
|
122
|
+
"meta": json.loads(lines[0]),
|
|
123
|
+
"cases": [json.loads(line) for line in lines[1:]],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def get_facts(self) -> list[dict[str, Any]]:
|
|
127
|
+
"""The sourced-constants registry (IRS limits, brackets, SSA factors)."""
|
|
128
|
+
return self._get_json("/api/v1/facts")["facts"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Decision-record verification ─────────────────────────────────────────────
|
|
132
|
+
# Every successful run carries record.sha256 — SHA-256 over the canonical JSON
|
|
133
|
+
# of {model, specVersion, inputs, outputs} with object keys sorted recursively
|
|
134
|
+
# and numbers rendered exactly as ECMAScript renders them (the server is
|
|
135
|
+
# JavaScript). Recompute it any time to prove a stored response's numbers came
|
|
136
|
+
# from that spec version with those inputs, unaltered.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _js_number_repr(value: float) -> str:
|
|
140
|
+
"""Render a float exactly as ECMAScript Number::toString(10) would.
|
|
141
|
+
|
|
142
|
+
Python and JavaScript both choose shortest round-trip digits, but format
|
|
143
|
+
them differently (e.g. Python ``5e-05`` vs JS ``0.00005``; Python
|
|
144
|
+
``1e+21`` matches JS, but Python switches to exponent form at different
|
|
145
|
+
thresholds). JSON numbers parsed from API responses hash correctly only
|
|
146
|
+
if re-rendered the way the server rendered them.
|
|
147
|
+
"""
|
|
148
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
149
|
+
raise ValueError("non-finite numbers cannot appear in JSON")
|
|
150
|
+
if value == 0:
|
|
151
|
+
return "0" # covers -0.0, which JS renders as "0"
|
|
152
|
+
sign = "-" if value < 0 else ""
|
|
153
|
+
d = Decimal(repr(abs(value))).normalize()
|
|
154
|
+
digit_tuple, exp = d.as_tuple()[1], int(d.as_tuple()[2])
|
|
155
|
+
digits = "".join(str(x) for x in digit_tuple)
|
|
156
|
+
k = len(digits)
|
|
157
|
+
n = exp + k # value = 0.<digits> * 10^n
|
|
158
|
+
if k <= n <= 21:
|
|
159
|
+
return sign + digits + "0" * (n - k)
|
|
160
|
+
if 0 < n <= 21:
|
|
161
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
162
|
+
if -6 < n <= 0:
|
|
163
|
+
return sign + "0." + "0" * (-n) + digits
|
|
164
|
+
# exponential form; JS writes e+21 / e-7 (no zero padding)
|
|
165
|
+
e = n - 1
|
|
166
|
+
e_str = f"e+{e}" if e >= 0 else f"e-{-e}"
|
|
167
|
+
if k == 1:
|
|
168
|
+
return sign + digits + e_str
|
|
169
|
+
return sign + digits[0] + "." + digits[1:] + e_str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def canonical_json(value: Any) -> str:
|
|
173
|
+
"""Serialize with object keys sorted recursively, no whitespace,
|
|
174
|
+
JS-compatible number rendering."""
|
|
175
|
+
if isinstance(value, bool):
|
|
176
|
+
return "true" if value else "false"
|
|
177
|
+
if value is None:
|
|
178
|
+
return "null"
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
return json.dumps(value, ensure_ascii=False)
|
|
181
|
+
if isinstance(value, int):
|
|
182
|
+
return str(value)
|
|
183
|
+
if isinstance(value, float):
|
|
184
|
+
return _js_number_repr(value)
|
|
185
|
+
if isinstance(value, (list, tuple)):
|
|
186
|
+
return "[" + ",".join(canonical_json(v) for v in value) + "]"
|
|
187
|
+
if isinstance(value, dict):
|
|
188
|
+
items = sorted(value.items(), key=lambda kv: kv[0])
|
|
189
|
+
return "{" + ",".join(
|
|
190
|
+
f"{json.dumps(k, ensure_ascii=False)}:{canonical_json(v)}" for k, v in items
|
|
191
|
+
) + "}"
|
|
192
|
+
raise TypeError(f"unsupported type in canonical JSON: {type(value)!r}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify_record(response: dict[str, Any]) -> bool:
|
|
196
|
+
"""Recompute a response's decision-record hash and compare."""
|
|
197
|
+
canonical = canonical_json(
|
|
198
|
+
{
|
|
199
|
+
"model": response["model"],
|
|
200
|
+
"specVersion": response["specVersion"],
|
|
201
|
+
"inputs": response["inputs"],
|
|
202
|
+
"outputs": response["outputs"],
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
206
|
+
return digest == response["record"]["sha256"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"ok":true,"model":"relocation","specVersion":"1.0.0","contractUrl":"/api/v1/models/relocation","specUrl":"/api/v1/models/relocation/spec","inputs":{"currentSalary":95000,"newSalary":108000,"currentMonthlyExpenses":4200,"newMonthlyExpenses":4900,"movingCosts":6000,"currentSavings":40000,"annualReturn":0.07,"yearsHorizon":10},"outputs":{"annualSalaryDelta":13000,"annualExpenseDelta":8400,"annualNetDelta":4600,"movingCostRecoveryMonths":16,"breakEvenMonths":16,"wealthAtHorizonStay":694899.6333646487,"wealthAtHorizonMove":746652.3858427971,"netWealthGain":51752.75247814844,"yearlyData":[{"year":0,"stay":40000,"move":34000},{"year":1,"stay":87400,"move":85580},{"year":2,"stay":138118,"move":140770.6},{"year":3,"stay":192386.26,"move":199824.54200000002},{"year":4,"stay":250453.29820000002,"move":263012.25994},{"year":5,"stay":312585.029074,"move":330623.11813580006},{"year":6,"stay":379065.98110918,"move":402966.7364053061},{"year":7,"stay":450200.5997868226,"move":480374.4079536775},{"year":8,"stay":526314.6417719002,"move":563200.616510435},{"year":9,"stay":607756.6666959333,"move":651824.6596661655},{"year":10,"stay":694899.6333646487,"move":746652.3858427971}]},"sentinels":[{"field":"breakEvenMonths","value":9999,"meaning":"never breaks even (monthlyNetDelta ≤ 0)","triggered":false}],"assumptions":["Implements model spec relocation v1.0.0 (dual-implementation verified)."],"facts":[],"record":{"sha256":"7891f5eac540c8e9252f832de1742778a0e54b8812d3e198fe289e1c46b6bfd9","fields":["model","specVersion","inputs","outputs"],"howToVerify":"Store this record with any advice or agent output built on these numbers. To verify later: build {model, specVersion, inputs, outputs} from the stored response, serialize as JSON with object keys sorted recursively (no whitespace), and SHA-256 it — a match proves the numbers came from this spec version with these inputs, unaltered."},"disclaimer":"Illustrative planning model, not financial advice. Outputs follow the published model spec exactly; read the spec for assumptions and exclusions before relying on any number."}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Offline tests: decision-record verification against a real captured
|
|
2
|
+
response, and JS-exact number rendering (ground truth generated with Node)."""
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
from worthune import _js_number_repr, canonical_json, verify_record # noqa: E402
|
|
13
|
+
|
|
14
|
+
FIXTURE = json.loads(
|
|
15
|
+
(pathlib.Path(__file__).parent / "fixture-relocation.json").read_text()
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Expected strings generated with `node -e 'console.log(String(v))'`.
|
|
19
|
+
JS_NUMBER_CASES = [
|
|
20
|
+
(0.07, "0.07"),
|
|
21
|
+
(4600.0, "4600"),
|
|
22
|
+
(32409.87, "32409.87"),
|
|
23
|
+
(0.00005, "0.00005"),
|
|
24
|
+
(5e-7, "5e-7"),
|
|
25
|
+
(1e21, "1e+21"),
|
|
26
|
+
(1.5e21, "1.5e+21"),
|
|
27
|
+
(123.456, "123.456"),
|
|
28
|
+
(100.0, "100"),
|
|
29
|
+
(-0.0, "0"),
|
|
30
|
+
(1e-6, "0.000001"),
|
|
31
|
+
(9007199254740991.0, "9007199254740991"),
|
|
32
|
+
(2.5e-8, "2.5e-8"),
|
|
33
|
+
(-42.75, "-42.75"),
|
|
34
|
+
(0.1, "0.1"),
|
|
35
|
+
(3e20, "300000000000000000000"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestDecisionRecord(unittest.TestCase):
|
|
40
|
+
def test_fixture_verifies(self):
|
|
41
|
+
self.assertTrue(FIXTURE["ok"])
|
|
42
|
+
self.assertTrue(verify_record(FIXTURE))
|
|
43
|
+
|
|
44
|
+
def test_tampering_detected(self):
|
|
45
|
+
tampered = copy.deepcopy(FIXTURE)
|
|
46
|
+
tampered["outputs"]["breakEvenMonths"] = 1
|
|
47
|
+
self.assertFalse(verify_record(tampered))
|
|
48
|
+
|
|
49
|
+
def test_key_order_independent(self):
|
|
50
|
+
reordered = copy.deepcopy(FIXTURE)
|
|
51
|
+
reordered["inputs"] = dict(reversed(list(reordered["inputs"].items())))
|
|
52
|
+
self.assertTrue(verify_record(reordered))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestJsNumberRepr(unittest.TestCase):
|
|
56
|
+
def test_matches_ecmascript(self):
|
|
57
|
+
for value, expected in JS_NUMBER_CASES:
|
|
58
|
+
self.assertEqual(_js_number_repr(value), expected, msg=repr(value))
|
|
59
|
+
|
|
60
|
+
def test_ints_pass_through_canonical_json(self):
|
|
61
|
+
self.assertEqual(canonical_json({"a": 4600, "b": True, "c": None}), '{"a":4600,"b":true,"c":null}')
|
|
62
|
+
|
|
63
|
+
def test_canonical_sorts_recursively(self):
|
|
64
|
+
self.assertEqual(
|
|
65
|
+
canonical_json({"b": 1, "a": {"d": [2, {"z": 3, "y": 4}], "c": 5}}),
|
|
66
|
+
'{"a":{"c":5,"d":[2,{"y":4,"z":3}]},"b":1}',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
unittest.main()
|