xfingine 0.0.1__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.
- xfingine-0.0.1/.cargo/config.toml +2 -0
- xfingine-0.0.1/.github/workflows/publish.yml +155 -0
- xfingine-0.0.1/.github/workflows/test.yml +72 -0
- xfingine-0.0.1/.gitignore +28 -0
- xfingine-0.0.1/AGENTS.md +116 -0
- xfingine-0.0.1/CHANGELOG.md +53 -0
- xfingine-0.0.1/CONTRIBUTING.md +99 -0
- xfingine-0.0.1/Cargo.lock +472 -0
- xfingine-0.0.1/Cargo.toml +47 -0
- xfingine-0.0.1/LICENSE +201 -0
- xfingine-0.0.1/PKG-INFO +87 -0
- xfingine-0.0.1/README.md +258 -0
- xfingine-0.0.1/pyproject.toml +26 -0
- xfingine-0.0.1/python/Cargo.toml +25 -0
- xfingine-0.0.1/python/LICENSE +201 -0
- xfingine-0.0.1/python/README.md +70 -0
- xfingine-0.0.1/python/src/lib.rs +52 -0
- xfingine-0.0.1/src/emi/engine.rs +458 -0
- xfingine-0.0.1/src/emi/mod.rs +36 -0
- xfingine-0.0.1/src/emi/model.rs +291 -0
- xfingine-0.0.1/src/error.rs +42 -0
- xfingine-0.0.1/src/lib.rs +58 -0
- xfingine-0.0.1/src/num.rs +44 -0
- xfingine-0.0.1/tests/data/emi_cases.json +146 -0
- xfingine-0.0.1/tests/data/emi_expected.json +1 -0
- xfingine-0.0.1/tests/emi_integration.rs +159 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
name: Multi-Ecosystem Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*.*.*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
publish_crates:
|
|
10
|
+
description: 'Publish to crates.io'
|
|
11
|
+
type: boolean
|
|
12
|
+
default: true
|
|
13
|
+
publish_npm:
|
|
14
|
+
description: 'Publish to NPM'
|
|
15
|
+
type: boolean
|
|
16
|
+
default: true
|
|
17
|
+
publish_pypi:
|
|
18
|
+
description: 'Publish to PyPI'
|
|
19
|
+
type: boolean
|
|
20
|
+
default: true
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
id-token: write
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
check_branch:
|
|
32
|
+
name: Verify Tag is on Main
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
with:
|
|
37
|
+
fetch-depth: 0
|
|
38
|
+
- name: Check if tag is on main
|
|
39
|
+
run: |
|
|
40
|
+
git fetch origin main
|
|
41
|
+
if ! git branch -r --contains ${{ github.sha }} | grep -E -q '(^|\s)origin/main$'; then
|
|
42
|
+
echo "Error: Tag ${{ github.ref_name }} is not on the main branch."
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
test:
|
|
47
|
+
name: Test Before Publish
|
|
48
|
+
needs: check_branch
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- uses: dtolnay/rust-toolchain@stable
|
|
53
|
+
with:
|
|
54
|
+
targets: wasm32-unknown-unknown
|
|
55
|
+
- uses: Swatinem/rust-cache@v2
|
|
56
|
+
- name: Run tests
|
|
57
|
+
run: cargo test --workspace --all-features
|
|
58
|
+
|
|
59
|
+
crates_io:
|
|
60
|
+
name: Publish to crates.io
|
|
61
|
+
if: github.event_name == 'push' || inputs.publish_crates != false
|
|
62
|
+
needs: test
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/checkout@v4
|
|
66
|
+
- name: Install Rust
|
|
67
|
+
uses: dtolnay/rust-toolchain@stable
|
|
68
|
+
- name: Cache Cargo dependencies
|
|
69
|
+
uses: Swatinem/rust-cache@v2
|
|
70
|
+
- name: Authenticate with crates.io
|
|
71
|
+
uses: rust-lang/crates-io-auth-action@v1
|
|
72
|
+
id: auth
|
|
73
|
+
- name: Publish xfingine crate
|
|
74
|
+
env:
|
|
75
|
+
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
|
76
|
+
run: cargo publish --package xfingine
|
|
77
|
+
|
|
78
|
+
npm:
|
|
79
|
+
name: Publish to NPM
|
|
80
|
+
if: github.event_name == 'push' || inputs.publish_npm != false
|
|
81
|
+
needs: test
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@v4
|
|
85
|
+
- name: Setup Node
|
|
86
|
+
uses: actions/setup-node@v4
|
|
87
|
+
with:
|
|
88
|
+
node-version: 22
|
|
89
|
+
registry-url: 'https://registry.npmjs.org'
|
|
90
|
+
- name: Setup Rust
|
|
91
|
+
uses: dtolnay/rust-toolchain@stable
|
|
92
|
+
with:
|
|
93
|
+
targets: wasm32-unknown-unknown
|
|
94
|
+
- name: Cache Cargo dependencies
|
|
95
|
+
uses: Swatinem/rust-cache@v2
|
|
96
|
+
- name: Install wasm-pack
|
|
97
|
+
run: |
|
|
98
|
+
if ! command -v wasm-pack &> /dev/null; then
|
|
99
|
+
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
|
100
|
+
fi
|
|
101
|
+
- name: Build WASM (Web Target)
|
|
102
|
+
working-directory: wasm
|
|
103
|
+
run: wasm-pack build --target web
|
|
104
|
+
- name: Publish to NPM
|
|
105
|
+
working-directory: wasm/pkg
|
|
106
|
+
run: |
|
|
107
|
+
npm install -g npm@latest
|
|
108
|
+
unset NODE_AUTH_TOKEN
|
|
109
|
+
npm publish --access public --provenance
|
|
110
|
+
|
|
111
|
+
pypi:
|
|
112
|
+
name: Publish to PyPI
|
|
113
|
+
if: github.event_name == 'push' || inputs.publish_pypi != false
|
|
114
|
+
needs: test
|
|
115
|
+
runs-on: ${{ matrix.os }}
|
|
116
|
+
strategy:
|
|
117
|
+
fail-fast: false
|
|
118
|
+
matrix:
|
|
119
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
120
|
+
steps:
|
|
121
|
+
- uses: actions/checkout@v4
|
|
122
|
+
- name: Setup Python
|
|
123
|
+
uses: actions/setup-python@v5
|
|
124
|
+
with:
|
|
125
|
+
python-version: '3.11'
|
|
126
|
+
- name: Install Maturin
|
|
127
|
+
run: pip install maturin
|
|
128
|
+
- name: Build Wheels
|
|
129
|
+
working-directory: python
|
|
130
|
+
run: maturin build --release --out dist
|
|
131
|
+
- name: Upload wheels
|
|
132
|
+
uses: actions/upload-artifact@v4
|
|
133
|
+
with:
|
|
134
|
+
name: wheels-${{ matrix.os }}
|
|
135
|
+
path: python/dist/
|
|
136
|
+
|
|
137
|
+
pypi_publish:
|
|
138
|
+
name: Upload to PyPI
|
|
139
|
+
if: github.event_name == 'push' || inputs.publish_pypi != false
|
|
140
|
+
needs: pypi
|
|
141
|
+
runs-on: ubuntu-latest
|
|
142
|
+
permissions:
|
|
143
|
+
id-token: write
|
|
144
|
+
steps:
|
|
145
|
+
- name: Download wheels
|
|
146
|
+
uses: actions/download-artifact@v4
|
|
147
|
+
with:
|
|
148
|
+
pattern: wheels-*
|
|
149
|
+
merge-multiple: true
|
|
150
|
+
path: dist
|
|
151
|
+
- name: Publish to PyPI
|
|
152
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
153
|
+
with:
|
|
154
|
+
packages-dir: dist/
|
|
155
|
+
skip-existing: true
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: PR Checks
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [ "main" ]
|
|
6
|
+
|
|
7
|
+
env:
|
|
8
|
+
CARGO_TERM_COLOR: always
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Approve PR
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
|
|
19
|
+
- name: Check Changes and Changelog
|
|
20
|
+
id: check_changes
|
|
21
|
+
run: |
|
|
22
|
+
git fetch origin ${{ github.base_ref }}
|
|
23
|
+
|
|
24
|
+
# Core paths that require a changelog entry (markdown excluded)
|
|
25
|
+
CORE_CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- src/ python/ wasm/ tests/ Cargo.toml | grep -v -i '\.md$' || true)
|
|
26
|
+
|
|
27
|
+
if [ -n "$CORE_CHANGED" ]; then
|
|
28
|
+
echo "The following core files were modified:"
|
|
29
|
+
echo "$CORE_CHANGED"
|
|
30
|
+
echo "----------------------------------------"
|
|
31
|
+
|
|
32
|
+
if ! git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^CHANGELOG.md$"; then
|
|
33
|
+
echo "::error::Core files were modified but CHANGELOG.md was not updated. Please document your changes."
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
echo "Success: CHANGELOG.md was updated!"
|
|
37
|
+
echo "run_tests=true" >> "$GITHUB_OUTPUT"
|
|
38
|
+
else
|
|
39
|
+
echo "No core files modified. Changelog update not strictly required."
|
|
40
|
+
echo "run_tests=false" >> "$GITHUB_OUTPUT"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
- name: Install Rust
|
|
44
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
45
|
+
uses: dtolnay/rust-toolchain@stable
|
|
46
|
+
with:
|
|
47
|
+
components: rustfmt, clippy
|
|
48
|
+
targets: wasm32-unknown-unknown
|
|
49
|
+
|
|
50
|
+
- name: Cache cargo registry and build
|
|
51
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
52
|
+
uses: Swatinem/rust-cache@v2
|
|
53
|
+
|
|
54
|
+
- name: Check formatting
|
|
55
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
56
|
+
run: cargo fmt --check
|
|
57
|
+
|
|
58
|
+
- name: Check for warnings
|
|
59
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
60
|
+
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
|
61
|
+
|
|
62
|
+
- name: Run tests
|
|
63
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
64
|
+
run: cargo test --workspace --all-features
|
|
65
|
+
|
|
66
|
+
- name: Build WASM
|
|
67
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
68
|
+
run: cargo build --target wasm32-unknown-unknown -p xfingine-wasm
|
|
69
|
+
|
|
70
|
+
- name: Build Python bindings
|
|
71
|
+
if: steps.check_changes.outputs.run_tests == 'true'
|
|
72
|
+
run: cargo check -p xfingine-py
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Cargo
|
|
2
|
+
# will have compiled files and executables
|
|
3
|
+
debug
|
|
4
|
+
target
|
|
5
|
+
|
|
6
|
+
# These are backup files generated by rustfmt
|
|
7
|
+
**/*.rs.bk
|
|
8
|
+
|
|
9
|
+
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
10
|
+
*.pdb
|
|
11
|
+
|
|
12
|
+
# Generated by cargo mutants
|
|
13
|
+
**/mutants.out*/
|
|
14
|
+
|
|
15
|
+
# WASM / npm build output
|
|
16
|
+
wasm/pkg/
|
|
17
|
+
node_modules/
|
|
18
|
+
|
|
19
|
+
# Python build output
|
|
20
|
+
python/dist/
|
|
21
|
+
python/target/
|
|
22
|
+
*.egg-info/
|
|
23
|
+
__pycache__/
|
|
24
|
+
.venv/
|
|
25
|
+
|
|
26
|
+
# Scratch and debug files
|
|
27
|
+
scratch/
|
|
28
|
+
*.rs.scratch
|
xfingine-0.0.1/AGENTS.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Xfingine — Agent Context & Guidelines
|
|
2
|
+
|
|
3
|
+
A cheat sheet for AI agents working on Xfingine, so you can get oriented without
|
|
4
|
+
re-deriving the architecture.
|
|
5
|
+
|
|
6
|
+
## What this project is (and is not)
|
|
7
|
+
|
|
8
|
+
Xfingine is a **pure computation library**. Input data → computation → output
|
|
9
|
+
data. There is **no UI, no CLI, no file I/O, no network, and no clock** in the
|
|
10
|
+
core crate, and none should be added. If a change requires reaching outside the
|
|
11
|
+
process, it belongs in the caller, not here.
|
|
12
|
+
|
|
13
|
+
This is the sibling project to [Xfina](https://github.com/sakthipriyan/xfina),
|
|
14
|
+
which parses financial statements. Xfina has a web app; Xfingine deliberately
|
|
15
|
+
does not. Consumers bring their own UI — today that is the tools page on
|
|
16
|
+
sakthipriyan.com, which loads the WASM bundle.
|
|
17
|
+
|
|
18
|
+
## Architecture Overview
|
|
19
|
+
|
|
20
|
+
- **Core (`src/`):** Plain Rust. One module per engine (`src/emi/`), each split
|
|
21
|
+
into `model.rs` (the serde types on the wire) and `engine.rs` (the maths).
|
|
22
|
+
Shared helpers live in `src/num.rs`; all errors are `XfingineError`.
|
|
23
|
+
- **WASM (`wasm/`):** `wasm-bindgen` wrappers published to npm as
|
|
24
|
+
`xfingine-wasm`. Every engine is exposed twice — object in/out, and a `_json`
|
|
25
|
+
twin taking and returning strings.
|
|
26
|
+
- **Python (`python/`):** `pyo3` + `pythonize` wrappers published to PyPI as
|
|
27
|
+
`xfingine`. Same dual shape: dict in/out, plus a `_json` twin.
|
|
28
|
+
- **`xtask/`:** Release automation only. Not published.
|
|
29
|
+
|
|
30
|
+
The bindings must stay **thin**. They deserialize, call one core function, and
|
|
31
|
+
serialize. No maths, no validation, no defaulting in a binding — if you find
|
|
32
|
+
yourself writing logic there, it belongs in the core so all three targets get
|
|
33
|
+
it.
|
|
34
|
+
|
|
35
|
+
## Build & Test
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cargo test --workspace --all-features
|
|
39
|
+
cargo fmt --check
|
|
40
|
+
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
|
41
|
+
cargo build --target wasm32-unknown-unknown -p xfingine-wasm
|
|
42
|
+
|
|
43
|
+
cd wasm && wasm-pack build --target web # npm package → wasm/pkg
|
|
44
|
+
cd python && maturin develop # importable module (needs a venv)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`maturin develop` needs `VIRTUAL_ENV` set or a `.venv` in the tree.
|
|
48
|
+
|
|
49
|
+
## Testing Workflow
|
|
50
|
+
|
|
51
|
+
- **Snapshots:** `tests/data/emi_cases.json` holds inputs, `emi_expected.json`
|
|
52
|
+
holds full recorded results. Unlike Xfina — whose fixtures contain PII and so
|
|
53
|
+
live outside the repo in `../xfina-test-data/` — these are pure numbers, so
|
|
54
|
+
they are **committed** and CI checks them directly.
|
|
55
|
+
- **Re-recording:** `UPDATE_EXPECTED=1 cargo test`. Never re-record to make a
|
|
56
|
+
failing test pass without first understanding *why* the numbers moved, and
|
|
57
|
+
say so in the changelog when they legitimately did.
|
|
58
|
+
- **Invariants:** `schedules_are_internally_consistent` asserts properties that
|
|
59
|
+
hold regardless of the numbers (rows sum exactly, balance reaches zero, the
|
|
60
|
+
per-year breakdown covers every payment once). Add to it when adding an
|
|
61
|
+
engine — it catches classes of bug snapshots cannot.
|
|
62
|
+
|
|
63
|
+
## Technical Rules & Conventions
|
|
64
|
+
|
|
65
|
+
1. **Money out is `i64` whole rupees.** Engines compute in `f64` and round at
|
|
66
|
+
the boundary, because that is what a lender debits. Never emit fractional
|
|
67
|
+
currency; paise drift accumulates badly across 360 rows.
|
|
68
|
+
2. **Use `num::js_round`, not `f64::round`.** The engines are ported from
|
|
69
|
+
JavaScript and must match it bit for bit. `Math.round` rounds half *up*;
|
|
70
|
+
Rust's `f64::round` rounds half *away from zero*. They differ on negatives.
|
|
71
|
+
3. **Rates are percentages, not fractions.** `9.0` means 9%. Convert to a
|
|
72
|
+
monthly fraction inside the engine, never at the API boundary.
|
|
73
|
+
4. **JSON is `camelCase`** on every type, via
|
|
74
|
+
`#[serde(rename_all = "camelCase")]`, so Rust, WASM and Python share one wire
|
|
75
|
+
format.
|
|
76
|
+
5. **No ambient clock.** Never call `SystemTime::now()` or equivalent. Dates are
|
|
77
|
+
opt-in through an explicit `start` field; without it the engine emits no
|
|
78
|
+
dates and an empty per-year breakdown. Determinism is the point.
|
|
79
|
+
6. **Errors, never panics.** Every fallible path returns `XfingineError`.
|
|
80
|
+
Validate inputs up front and name the offending field in the message, using
|
|
81
|
+
the `camelCase` name the caller actually passed (`loanAmount`, not
|
|
82
|
+
`loan_amount`).
|
|
83
|
+
7. **Every engine sits behind its own Cargo feature,** included in `all`, so a
|
|
84
|
+
WASM bundle only carries the maths it uses.
|
|
85
|
+
8. **`#![warn(missing_docs)]` is on.** Public items need doc comments. Say what
|
|
86
|
+
a number *means*, not just its type — `real_interest` needs the sentence
|
|
87
|
+
about discounting far more than it needs "the real interest".
|
|
88
|
+
|
|
89
|
+
## Porting an engine from the JavaScript tools
|
|
90
|
+
|
|
91
|
+
The engines originate as `.js` files in
|
|
92
|
+
`../sakthipriyan.github.io/static/js/`. When porting one:
|
|
93
|
+
|
|
94
|
+
1. Extract *only* the maths — the JS files interleave it with Vue templates and
|
|
95
|
+
ECharts wiring.
|
|
96
|
+
2. Build a differential harness: run the extracted JS and the Rust side over the
|
|
97
|
+
same inputs and compare **every field of every row**, not just the totals.
|
|
98
|
+
Fuzz it with a few hundred random cases too; the interesting bugs live in
|
|
99
|
+
final-row and zero-rate edges.
|
|
100
|
+
3. Where the Rust deliberately diverges from the JS — a rounding bug in the
|
|
101
|
+
original, say — record it in `CHANGELOG.md` and explain why, so nobody
|
|
102
|
+
"fixes" it back later.
|
|
103
|
+
|
|
104
|
+
## Release Process
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
cargo xtask prepare-release <major|minor|patch> # bumps version, rolls changelog, branches
|
|
108
|
+
# push, open a PR, merge to main
|
|
109
|
+
cargo xtask tag-release # tags main and pushes
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The tag fires `.github/workflows/publish.yml` → crates.io, npm, PyPI in
|
|
113
|
+
parallel. PRs touching `src/`, `wasm/`, `python/`, `tests/` or `Cargo.toml`
|
|
114
|
+
**must** update `CHANGELOG.md`; CI fails the PR otherwise.
|
|
115
|
+
|
|
116
|
+
Open the PR and stop — merging is the maintainer's call, per PR.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-27
|
|
11
|
+
|
|
12
|
+
Initial release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Core:** The `xfingine` crate — a pure computation layer for personal finance
|
|
17
|
+
planning. Data in, arithmetic, data out: no UI, no I/O, no network, no clock.
|
|
18
|
+
- **RealValue EMI Engine (`emi` feature):** Loan amortization reported in both
|
|
19
|
+
nominal rupees and rupees discounted for inflation. Solves for any one of
|
|
20
|
+
monthly payment, tenure, or loan amount given the other two, and returns
|
|
21
|
+
headline totals, the full month-by-month schedule, and an optional
|
|
22
|
+
per-calendar-year breakdown. Ported from `realvalue-emi-engine.js` on
|
|
23
|
+
sakthipriyan.com.
|
|
24
|
+
- **WASM bindings (`xfingine-wasm` on npm):** Each engine exposed as both an
|
|
25
|
+
object-in/object-out function and a `_json` string variant.
|
|
26
|
+
- **Python bindings (`xfingine` on PyPI):** Each engine exposed as both a
|
|
27
|
+
dict-in/dict-out function and a `_json` string variant, via `pyo3`.
|
|
28
|
+
- **Cargo features:** One feature per engine, all enabled by `default`, so a
|
|
29
|
+
WASM bundle carries only the maths it uses.
|
|
30
|
+
- **Tests:** Committed snapshots over 16 scenarios, structural invariant checks,
|
|
31
|
+
and unit coverage of the formulas, degenerate zero-rate cases, and every error
|
|
32
|
+
path.
|
|
33
|
+
- **CI/CD:** PR checks gating on formatting, clippy, tests and a changelog
|
|
34
|
+
entry; tag-triggered publishing to crates.io, npm and PyPI.
|
|
35
|
+
- **`xtask`:** `prepare-release` and `tag-release` for cutting versions.
|
|
36
|
+
|
|
37
|
+
### Notes on the port
|
|
38
|
+
|
|
39
|
+
The EMI engine was verified differentially against the original JavaScript, not
|
|
40
|
+
merely tested in isolation: both implementations were run over the same inputs
|
|
41
|
+
and compared field by field across every schedule row — 16 hand-picked scenarios
|
|
42
|
+
(2,691 rows) and 600 randomized cases (133,686 rows). Output is bit-identical
|
|
43
|
+
apart from one deliberate difference:
|
|
44
|
+
|
|
45
|
+
- **Fixed:** the JavaScript rounds every payment to whole rupees *except the
|
|
46
|
+
final one*, where it assigns the raw floating-point balance as the closing
|
|
47
|
+
principal. On loans whose principal is not itself a round number this made the
|
|
48
|
+
last row — and therefore the nominal total — carry fractional paise, and could
|
|
49
|
+
report a *real* final payment marginally larger than the nominal one, which is
|
|
50
|
+
incoherent. Xfingine rounds the closing principal like every other row, so
|
|
51
|
+
`emi == principal + interest` holds in whole rupees on all rows and the
|
|
52
|
+
schedule sums exactly to the totals. The difference only surfaces on the final
|
|
53
|
+
row of a loan with a non-round principal.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Contributing to Xfingine
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in Xfingine. The most valuable contribution is a new
|
|
4
|
+
**engine** — a self-contained computation that takes data in and gives data
|
|
5
|
+
back.
|
|
6
|
+
|
|
7
|
+
## Getting Started
|
|
8
|
+
|
|
9
|
+
1. **Fork the repository.**
|
|
10
|
+
2. **Install Rust** via [rustup](https://rustup.rs/).
|
|
11
|
+
3. **Build and test:**
|
|
12
|
+
```bash
|
|
13
|
+
cargo build
|
|
14
|
+
cargo test --workspace --all-features
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For the bindings you will also want
|
|
18
|
+
[`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and
|
|
19
|
+
[`maturin`](https://www.maturin.rs/).
|
|
20
|
+
|
|
21
|
+
## Scope
|
|
22
|
+
|
|
23
|
+
Xfingine is a **pure library**: no UI, no CLI, no file I/O, no network, and no
|
|
24
|
+
clock. Anything that reaches outside the process belongs in the caller. Pull
|
|
25
|
+
requests that add such dependencies to the core crate will be asked to move
|
|
26
|
+
them out.
|
|
27
|
+
|
|
28
|
+
## Adding a New Engine
|
|
29
|
+
|
|
30
|
+
Engines live in `src/<engine>/`, split into `model.rs` (the serde types) and
|
|
31
|
+
`engine.rs` (the maths).
|
|
32
|
+
|
|
33
|
+
1. **Create the module.** Add `src/<engine>/mod.rs`, `model.rs` and `engine.rs`,
|
|
34
|
+
and declare it in `src/lib.rs` behind `#[cfg(feature = "<engine>")]`.
|
|
35
|
+
2. **Model the data.** Put every type on the wire in `model.rs` with
|
|
36
|
+
`#[serde(rename_all = "camelCase")]`. Provide builder-style constructors for
|
|
37
|
+
the common cases, the way `EmiRequest::emi(..)` does.
|
|
38
|
+
3. **Return errors, never panic.** Everything fallible returns
|
|
39
|
+
`Result<T, XfingineError>`. Validate inputs up front, and name the offending
|
|
40
|
+
field using the `camelCase` name the caller passed.
|
|
41
|
+
4. **Add the feature flag** in `Cargo.toml` and include it in `all`.
|
|
42
|
+
5. **Wire up the targets** — both are one line each, thanks to the macros:
|
|
43
|
+
- `wasm/src/lib.rs`: `bind_engine!(compute_x_json, compute_x, XRequest, xfingine::x::compute)`
|
|
44
|
+
- `python/src/lib.rs`: `bind_engine!(compute_x, compute_x_json, XRequest, ::xfingine::x::compute)`
|
|
45
|
+
and register both in the `#[pymodule]` block.
|
|
46
|
+
- Mirror the feature flag in `wasm/Cargo.toml` and `python/Cargo.toml`.
|
|
47
|
+
6. **Document it** in `README.md`, `wasm/README.md` and `python/README.md`.
|
|
48
|
+
|
|
49
|
+
Keep the bindings thin. They deserialize, call one core function, and serialize.
|
|
50
|
+
Any logic there is logic the other two targets silently miss.
|
|
51
|
+
|
|
52
|
+
## Testing Requirements
|
|
53
|
+
|
|
54
|
+
Tests are mandatory. Because the engines are pure maths with no PII, all
|
|
55
|
+
fixtures are committed to this repository — there is no external test-data repo.
|
|
56
|
+
|
|
57
|
+
1. **Snapshot test.** Add inputs to `tests/data/<engine>_cases.json` and record
|
|
58
|
+
the output:
|
|
59
|
+
```bash
|
|
60
|
+
UPDATE_EXPECTED=1 cargo test
|
|
61
|
+
```
|
|
62
|
+
Commit both files. Follow the pattern in `tests/emi_integration.rs`:
|
|
63
|
+
```rust
|
|
64
|
+
if std::env::var("UPDATE_EXPECTED").as_deref() == Ok("1") {
|
|
65
|
+
fs::write(EXPECTED, serde_json::to_string(&actual).unwrap()).unwrap();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let expected: Vec<XResult> = serde_json::from_str(&fs::read_to_string(EXPECTED)?)?;
|
|
69
|
+
assert_eq!(expected, actual);
|
|
70
|
+
```
|
|
71
|
+
2. **Invariant tests.** Assert what must hold whatever the numbers are — totals
|
|
72
|
+
reconcile with rows, balances land exactly on zero, monotonic relationships
|
|
73
|
+
stay monotonic. These catch what snapshots cannot.
|
|
74
|
+
3. **Unit tests** in `engine.rs` for the formulas themselves, the zero-rate
|
|
75
|
+
degenerate cases, and every error path.
|
|
76
|
+
|
|
77
|
+
### Porting from the JavaScript tools
|
|
78
|
+
|
|
79
|
+
If you are porting an engine from `sakthipriyan.com`'s existing `.js` tools,
|
|
80
|
+
verify it **differentially**: run the original JavaScript and your Rust over the
|
|
81
|
+
same inputs and compare every field of every row, plus a few hundred randomized
|
|
82
|
+
cases. Totals agreeing is not enough — rounding bugs hide in the final row.
|
|
83
|
+
|
|
84
|
+
Where you deliberately diverge from the original, record it in `CHANGELOG.md`
|
|
85
|
+
with the reasoning, so it is not "fixed" back later.
|
|
86
|
+
|
|
87
|
+
## Pull Request Process
|
|
88
|
+
|
|
89
|
+
1. `cargo test --workspace --all-features` passes.
|
|
90
|
+
2. `cargo fmt --check` is clean.
|
|
91
|
+
3. `cargo clippy --workspace --all-targets --all-features -- -D warnings` is clean.
|
|
92
|
+
4. **Update `CHANGELOG.md`** under `## [Unreleased]`. CI fails any PR touching
|
|
93
|
+
`src/`, `wasm/`, `python/`, `tests/` or `Cargo.toml` without one.
|
|
94
|
+
5. Open the PR against `main`.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
By contributing, you agree that your contributions will be licensed under the
|
|
99
|
+
Apache 2.0 License.
|