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.
@@ -0,0 +1,2 @@
1
+ [alias]
2
+ xtask = "run --package xtask --"
@@ -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
@@ -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.