LethaboScofield commited on
Commit
20ab5a9
·
verified ·
1 Parent(s): 1abfa24

Publish FinIR-Intent 0.1.0 (deterministic structured-output baseline)

Browse files
README.md CHANGED
@@ -1,3 +1,241 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ language: en
4
+ tags:
5
+ - finance
6
+ - structured-generation
7
+ - rule-based
8
+ - finir
9
  ---
10
+
11
+ # Model Card: FinIR-Intent (baseline v0.1.0)
12
+
13
+ **This is a deterministic structured-output baseline, not a trained language model.**
14
+ It maps a natural-language financial instruction to the canonical **FinIR Intent
15
+ Contract** (a versioned JSON envelope) using a fixed, fully offline rule set. There
16
+ are no neural weights in this repository.
17
+
18
+ ## What FinIR is
19
+
20
+ [FinIR](https://github.com/Olyxee/finir) is a financial intermediate representation
21
+ and incremental execution runtime for AI systems: a finance-typed computation graph
22
+ that is validated, compiled, and evaluated with dependency-aware incremental reuse.
23
+ It is published on PyPI (`pip install finir`, runtime `0.1.0`).
24
+
25
+ ## What FinIR-Intent is
26
+
27
+ FinIR-Intent is the natural-language layer:
28
+
29
+ ```
30
+ natural-language financial request -> FinIR-Intent -> canonical FinIR Intent Contract (v1.0)
31
+ ```
32
+
33
+ It performs **no financial computation**. Interpretation and execution are strictly
34
+ separated: FinIR-Intent only produces the envelope; the FinIR runtime validates and
35
+ executes it (`finir.intent.execute_intent` / `FinancialModel.apply_intent`).
36
+
37
+ ## The problem it solves
38
+
39
+ Letting an AI system "just compute" a financial what-if invites silently invented
40
+ numbers and unit/currency errors. FinIR-Intent constrains the language model's job
41
+ to emitting a **typed, validated intent**; the runtime is the single authority on
42
+ whether that intent is executable and what it computes. Vague language becomes an
43
+ explicit `ambiguous` status instead of a fabricated percentage.
44
+
45
+ ## What it is / is not
46
+
47
+ - **Is**: a small, dependency-free, offline pattern-matching compiler
48
+ (`src/finir_intent/baseline.py`) — no network, no external LLM/API calls, fully
49
+ reproducible. It implements the same `finir.intent.IntentCompiler` seam a future
50
+ LLM-backed compiler would, so it is a drop-in baseline to measure against.
51
+ - **Is not**: a trained/fine-tuned Transformer. Per the workstream brief, "the first
52
+ milestone is not model training… only after the baseline is measured should we
53
+ decide whether fine-tuning a small open model provides a meaningful improvement."
54
+ This baseline exists to make that measurement possible.
55
+
56
+ ## Hugging Face artifact type
57
+
58
+ Because v0.1.0 ships **code, not weights**, the natural representation is a
59
+ **model repository that contains the baseline package plus this card** — a
60
+ code/inference repo, not a weight checkpoint. The companion benchmark ships as a
61
+ **Datasets** repo, and a **Space** demonstrates the end-to-end flow against the real
62
+ runtime. The page makes explicit that this is a baseline compiler, not neural
63
+ weights. (See `../release/huggingface/` for the export layout.)
64
+
65
+ ## Schema / runtime compatibility
66
+
67
+ - **FinIR Intent schema version:** `1.0`
68
+ - **Compatible FinIR runtime:** `>=0.1.0,<0.2.0` (verified against the public PyPI
69
+ `finir==0.1.0`)
70
+ - **FinIR-Intent baseline version:** `0.1.0`
71
+ - The canonical contract is owned by the core `finir` package
72
+ (`finir.intent.json_schema()`, `schemas/finir-intent-v1.schema.json`). This
73
+ package **consumes** it and never redefines it.
74
+
75
+ ## Supported operations
76
+
77
+ | operation | meaning |
78
+ |---|---|
79
+ | `relative_change` | `new = current × (1 + value)` (dimensionless decimal; `-0.08` = −8%) |
80
+ | `set` | `new = value` (with optional `unit` / `currency`) |
81
+ | `absolute_change` | `new = current + value` (with optional `unit` / `currency`) |
82
+ | `range` | sweep `target` over `[min, max]` in `steps` (sole op) |
83
+ | `scenarios` | named scenarios, each a simultaneous operation set |
84
+
85
+ ## Supported targets
86
+
87
+ Raw model-input node names (no canonical ontology; alias resolution happens in this
88
+ package only, never in the contract): `revenue`, `cogs`, `opex`, `payment_terms`,
89
+ `accounts_payable`, `inventory`, `capex`, `debt`, `interest_rate`, `cash`, `price`,
90
+ `volume`.
91
+
92
+ ## Ambiguity / unsupported / invalid behavior
93
+
94
+ - **Ambiguous** — a target with no parseable quantity, or vague language → `status:
95
+ "ambiguous"`, empty operations. **No number is ever invented.** Conflicting
96
+ operations on one target (e.g. "increase revenue by 5% and also cut revenue by
97
+ 10%") also map to `ambiguous` rather than silently picking one.
98
+ - **Unsupported** — clearly out-of-domain (acquisitions, mergers, hiring/layoffs,
99
+ IPOs/going public, litigation, buybacks, bankruptcy) → `status: "unsupported"`.
100
+ - **Invalid (semantic)** — a structurally valid but semantically wrong instruction
101
+ (a currency the target does not use, a `days` unit on a money target) is
102
+ **transcribed faithfully, never "corrected"**; the FinIR runtime rejects it at
103
+ execution. This package performs no semantic check itself (no duplicated execution
104
+ logic).
105
+
106
+ ## Evaluation methodology
107
+
108
+ Reproduce (deterministic; no network, no LLM):
109
+
110
+ ```bash
111
+ pip install finir==0.1.0
112
+ cd finir_intent
113
+ pip install -e ".[dev]" # or: PYTHONPATH=src, plus jsonschema
114
+ python eval/evaluate.py # writes eval/results/latest.json
115
+ ```
116
+
117
+ `eval/evaluate.py` runs the baseline over every
118
+ `intentbench/examples/intentbench_v1.jsonl` example, validates each prediction
119
+ against `finir.intent.json_schema()` (the canonical schema), scores it against the
120
+ paired **ground-truth** expected intent, and — for every executable prediction —
121
+ actually executes it against a small reference `FinancialModel`
122
+ (`src/finir_intent/reference_model.py`) via the real `finir.intent.execute_intent`.
123
+ Every number below is computed from that run; none is hand-typed.
124
+
125
+ ### Benchmark split (anti-leakage)
126
+
127
+ FinIR-IntentBench is split into **core** (in-distribution phrasing the rule set is
128
+ built to support) and a held-out **stress** subset (paraphrases the baseline was
129
+ **not** tuned against — unlisted verbs, fractions, magnitude suffixes, idioms). The
130
+ baseline was deliberately **not** modified to pass stress cases, so the stress
131
+ numbers are an honest measure of the rule set's real coverage gap, not a tuned
132
+ score.
133
+
134
+ ## Evaluation results
135
+
136
+ Produced by an actual run of `python eval/evaluate.py` on
137
+ `intentbench_v1.jsonl` (183 examples: 143 core, 40 stress). Full per-example output,
138
+ including every prediction and its execution outcome against the real runtime, is in
139
+ `eval/results/latest.json`. **Re-run the command above to reproduce** if the code has
140
+ changed.
141
+
142
+ | metric | overall (n=183) | core (n=143) | stress (n=40) |
143
+ |---|---|---|---|
144
+ | schema validity | 1.0000 | 1.0000 | 1.0000 |
145
+ | status accuracy | 0.9344 | 1.0000 | 0.7000 |
146
+ | operation accuracy | 0.9515 | 1.0000 | 0.7500 |
147
+ | target accuracy | 0.9515 | 1.0000 | 0.7500 |
148
+ | value accuracy | 0.9363 | 1.0000 | 0.5833 |
149
+ | unit accuracy | 1.0000 | 1.0000 | 1.0000 |
150
+ | currency accuracy | 1.0000 | 1.0000 | 1.0000 |
151
+ | ambiguity precision | 0.8409 | 1.0000 | 0.5333 |
152
+ | ambiguity recall | 0.9737 | 1.0000 | 0.8889 |
153
+ | ambiguity F1 | 0.9024 | 1.0000 | 0.6666 |
154
+ | multi-operation exact-match | 0.9000 | 1.0000 | 0.0000 |
155
+ | scenario exact-match | 1.0000 | 1.0000 | n/a |
156
+ | runtime execution success (executable preds) | 1.0000 | 1.0000 | 1.0000 |
157
+ | semantic-rejection correctness | 1.0000 | 1.0000 | 1.0000 |
158
+
159
+ **Do not read the overall numbers as a headline score.** They are a blend of a
160
+ saturated core set and a deliberately hard stress set. The core row shows what the
161
+ baseline reliably does; the stress row shows where a fixed rule set breaks.
162
+
163
+ ## Known failure cases (from the stress subset)
164
+
165
+ Every current stress failure is one of two kinds — and **11 of 12 are conservative
166
+ refusals, never an invented number**:
167
+
168
+ - **Refuses (safe):** `valid → ambiguous` on phrasing outside the rule set —
169
+ fractions/idioms ("Reduce COGS by a fifth", "Double the unit price", "Halve
170
+ inventory"), an unlisted-target reference ("Increase AP by R2,000,000", "Reduce
171
+ the wage bill by 6%"), and an unlisted additive idiom ("Add R5,000,000 to opex").
172
+ - **Refuses (safe):** `unsupported → ambiguous` on out-of-domain phrasing the
173
+ vocabulary does not list ("Spin off the retail division", "Issue new equity",
174
+ "Relocate the head office", "Replace the CEO").
175
+ - **Value errors (status still valid):** an unlisted direction verb defaults the
176
+ sign to positive ("Slash opex by 12%" is parsed as +12%), and a magnitude suffix
177
+ on a digit is not expanded ("Increase opex by R5m" is parsed as R5, not R5m).
178
+ - **The one genuinely unsafe failure:** "Grow cogs by 4% but also reduce cogs by 2%"
179
+ is parsed as a single +4% change because `but also` is not a clause separator, so
180
+ the conflicting second operation is dropped instead of triggering `ambiguous`.
181
+ This is the only stress case where the baseline commits to a number it should have
182
+ refused; it is tracked for a future fix.
183
+
184
+ ## Limitations
185
+
186
+ This is a fixed rule set — a hand-authored alias table, a small number-word
187
+ vocabulary, keyword-anchored regexes — **not** a trained model. Any phrasing outside
188
+ what is enumerated in `src/finir_intent/baseline.py` falls back to `ambiguous`
189
+ rather than being guessed. It does not handle: fractional/idiomatic magnitudes ("a
190
+ fifth", "double", "halve"), magnitude suffixes on digits (`R5m`, `$2m`, `R1.2bn`),
191
+ unlisted direction verbs (slash/shave/ramp/shrink…), unlisted target aliases,
192
+ period/time scoping (forbidden by the v1.0 contract), or conflicting operations
193
+ joined by connectives other than `and`/`,`/`;`. Growing FinIR-IntentBench with new
194
+ adversarial paraphrases (as the stress subset does) is the honest way to keep
195
+ measuring this gap rather than hide it.
196
+
197
+ ## Fixed parser issues (regression-tested)
198
+
199
+ - **Word-boundary matching** for direction and unsupported words: naive substring
200
+ checks previously false-positived on ordinary English ("up" inside
201
+ "supplier"/"group" flipped a decrease's sign; "merge" inside "emergency" and "sue"
202
+ inside "issue" misclassified valid instructions as `unsupported`).
203
+ - **`set <money-target> to N%`** now refuses (`ambiguous`) instead of silently
204
+ becoming a relative change.
205
+ - **Spelled-out numbers** ("five percent", "five million rand") parse via a small
206
+ fixed number-word vocabulary.
207
+ - **`trim`/`trims`** added to the down-direction words ("trim cogs by 4 percent" →
208
+ −4%).
209
+ - **`boost`/`boosts`, `raises`** added to the up-direction words.
210
+ - **Layoff/going-public phrasings with a number or filler between the trigger
211
+ words** ("fire 100 employees", "take the company public") are now correctly
212
+ `unsupported`, anchored to headcount/listing nouns so "fire up the pipeline" is not
213
+ a false positive.
214
+
215
+ All are covered by unit tests in `tests/test_baseline.py` and by dataset entries.
216
+
217
+ ## License
218
+
219
+ Apache-2.0, matching the core FinIR repository. The benchmark is entirely synthetic;
220
+ no real company or personal data.
221
+
222
+ ## Repository & dependency
223
+
224
+ - Source & docs: https://github.com/Olyxee/finir (workstream under `finir_intent/`)
225
+ - Runtime dependency: `finir>=0.1.0,<0.2.0` — `pip install finir`
226
+ - Intent Contract spec: `docs/intent-contract.md`, `docs/huggingface-intent-handoff.md`
227
+
228
+ ## Attribution
229
+
230
+ The FinIR-Intent Hugging Face workstream (baseline, benchmark, evaluation, Space) was
231
+ contributed by **Alisha Fatima** ([@AlishaFatima16](https://github.com/AlishaFatima16)).
232
+ The core FinIR runtime and the canonical FinIR Intent Contract are maintained by
233
+ Olyxee.
234
+
235
+
236
+ ## Links
237
+
238
+ - **FinIR runtime (PyPI):** https://pypi.org/project/finir/ — `pip install finir`
239
+ - **FinIR source (GitHub):** https://github.com/Olyxee/finir
240
+ - **FinIR-IntentBench (dataset):** https://huggingface.co/datasets/Olyxee/FinIR-IntentBench
241
+ - **FinIR Space (demo):** https://huggingface.co/spaces/Olyxee/FinIR-Intent-Demo
pyproject.toml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "finir-intent"
7
+ dynamic = ["version"]
8
+ description = "FinIR-Intent: natural-language financial instructions to the canonical FinIR Intent Contract."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ # FinIR-Intent (the Hugging Face natural-language workstream) was contributed by
13
+ # Alisha Fatima (@AlishaFatima16). No private email is recorded here by request; the
14
+ # public author string can be adjusted later if needed.
15
+ authors = [{ name = "Alisha Fatima (@AlishaFatima16)" }]
16
+ maintainers = [{ name = "Olyxee" }]
17
+ keywords = ["finir", "financial-nlp", "structured-generation", "hugging-face"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: Apache Software License",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Office/Business :: Financial",
26
+ ]
27
+
28
+ dependencies = [
29
+ # Compatible FinIR runtime (published on PyPI). The contract range is stated in
30
+ # docs/huggingface-intent-handoff.md and src/finir_intent/_version.py.
31
+ "finir>=0.1.0,<0.2.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ space = ["gradio>=4.0"]
36
+ dev = [
37
+ "pytest>=8.1",
38
+ "jsonschema>=4.21",
39
+ ]
40
+
41
+ [tool.hatch.version]
42
+ path = "src/finir_intent/_version.py"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/finir_intent"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
50
+ src = ["src", "tests", "eval", "space"]
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B", "C4", "SIM", "RUF"]
54
+ ignore = ["E501"] # line length is owned by the formatter, matching the core finir repo
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ pythonpath = ["src"]
src/finir_intent/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FinIR-Intent: the Hugging Face natural-language layer for the FinIR project.
2
+
3
+ This package *interprets* natural-language financial instructions into the
4
+ canonical FinIR Intent Contract (schema version 1.0) defined by the core ``finir``
5
+ package (``finir.intent``). It performs no financial computation itself -- that is
6
+ the FinIR runtime's job, reached via ``finir.intent.execute_intent`` /
7
+ ``FinancialModel.apply_intent``.
8
+
9
+ from finir_intent import compile_intent
10
+ from finir.intent import FinIRIntent
11
+
12
+ envelope = compile_intent("Increase COGS by 4%")
13
+ intent = FinIRIntent.from_obj(envelope) # structural validation (core package)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .baseline import BaselineIntentCompiler, compile_intent
19
+ from .reference_model import build_reference_model
20
+
21
+ __all__ = [
22
+ "BaselineIntentCompiler",
23
+ "build_reference_model",
24
+ "compile_intent",
25
+ ]
src/finir_intent/_version.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Version metadata for finir-intent (the Hugging Face workstream package)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # The FinIR Intent Contract schema version this baseline emits.
8
+ INTENT_SCHEMA_VERSION = "1.0"
9
+
10
+ # The FinIR core runtime compatibility range this baseline was built against
11
+ # (must match docs/huggingface-intent-handoff.md in the finir repo).
12
+ FINIR_RUNTIME_COMPATIBLE = ">=0.1.0,<0.2.0"
src/finir_intent/baseline.py ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FinIR-Intent baseline: a deterministic, rule-based NL -> FinIR Intent compiler.
2
+
3
+ This is the constrained structured-output baseline for the first milestone (see
4
+ docs/huggingface-intent-handoff.md in the FinIR repo). It is intentionally *not* a
5
+ trained model: a small, fully offline, dependency-free pattern matcher that emits
6
+ exactly the canonical envelope defined by ``finir.intent`` -- the same JSON Schema
7
+ and Python types the core runtime already validates and executes.
8
+
9
+ Design boundary (do not blur this):
10
+
11
+ * This module only *interprets* natural language into the canonical envelope.
12
+ * It never performs financial arithmetic beyond parsing a number out of text.
13
+ * It never invents a quantity for vague language -- that maps to ``ambiguous``.
14
+ * It never guesses which of several conflicting operations on the same target the
15
+ user meant -- that also maps to ``ambiguous`` rather than silently picking one.
16
+ * Target names, units and the operation vocabulary are exactly the ones in
17
+ ``finir.intent.schema`` / ``schemas/finir-intent-v1.schema.json``. No new fields,
18
+ no alias resolution inside the contract -- alias resolution happens here, before
19
+ the envelope is emitted, exactly as ``docs/intent-contract.md`` requires.
20
+
21
+ Supported phrasing is deliberately narrow and documented in MODEL_CARD.md. Natural
22
+ language outside these patterns correctly falls back to ``ambiguous`` (a target was
23
+ named but no parseable quantity was found) rather than being guessed.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from typing import Any
30
+
31
+ from finir.intent import SCHEMA_VERSION, IntentCompiler
32
+
33
+ # --------------------------------------------------------------------------- targets
34
+ # Canonical target -> the finance "kind" used to decide default unit/currency
35
+ # handling. This mirrors 1:1 the input types declared in
36
+ # finir_intent.reference_model.build_reference_model(), so every "valid" envelope
37
+ # this baseline emits is also executable end-to-end against that model.
38
+ _TARGET_KIND: dict[str, str] = {
39
+ "revenue": "money",
40
+ "cogs": "money",
41
+ "opex": "money",
42
+ "payment_terms": "days",
43
+ "accounts_payable": "money",
44
+ "inventory": "money",
45
+ "capex": "money",
46
+ "debt": "money",
47
+ "interest_rate": "percentage",
48
+ "cash": "money",
49
+ "price": "money",
50
+ "volume": "quantity",
51
+ }
52
+
53
+ # NL synonym -> canonical target (model input node name). This alias table lives
54
+ # entirely in the interpretation layer -- the runtime resolves no aliases
55
+ # (docs/intent-contract.md "Targets").
56
+ _TARGET_ALIASES: dict[str, str] = {
57
+ "revenue": "revenue",
58
+ "sales": "revenue",
59
+ "top line": "revenue",
60
+ "cogs": "cogs",
61
+ "cost of goods sold": "cogs",
62
+ "cost of goods": "cogs",
63
+ "cost of sales": "cogs",
64
+ "supplier cost": "cogs",
65
+ "supplier costs": "cogs",
66
+ "opex": "opex",
67
+ "operating expenses": "opex",
68
+ "operating expense": "opex",
69
+ "operating costs": "opex",
70
+ "payment terms": "payment_terms",
71
+ "customer payment terms": "payment_terms",
72
+ "receivable terms": "payment_terms",
73
+ "credit terms": "payment_terms",
74
+ "accounts payable": "accounts_payable",
75
+ "trade payables": "accounts_payable",
76
+ "payables balance": "accounts_payable",
77
+ "amount owed to suppliers": "accounts_payable",
78
+ "supplier invoices": "accounts_payable",
79
+ "inventory levels": "inventory",
80
+ "inventory": "inventory",
81
+ "stock levels": "inventory",
82
+ "stock": "inventory",
83
+ "capital expenditure": "capex",
84
+ "capital expenditures": "capex",
85
+ "capital spending": "capex",
86
+ "capex": "capex",
87
+ "borrowings": "debt",
88
+ "loan balance": "debt",
89
+ "total debt": "debt",
90
+ "debt": "debt",
91
+ "interest rate": "interest_rate",
92
+ "cost of debt": "interest_rate",
93
+ "borrowing rate": "interest_rate",
94
+ "cash balance": "cash",
95
+ "cash on hand": "cash",
96
+ "cash position": "cash",
97
+ "cash": "cash",
98
+ "unit price": "price",
99
+ "selling price": "price",
100
+ "sale price": "price",
101
+ "price": "price",
102
+ "sales volume": "volume",
103
+ "unit volume": "volume",
104
+ "units sold": "volume",
105
+ "volume": "volume",
106
+ }
107
+ _SORTED_ALIASES = sorted(_TARGET_ALIASES, key=len, reverse=True)
108
+
109
+ _UP_WORDS = (
110
+ "increase",
111
+ "increases",
112
+ "raise",
113
+ "raises",
114
+ "grow",
115
+ "grows",
116
+ "rise",
117
+ "rises",
118
+ "up",
119
+ "higher",
120
+ "extend",
121
+ "boost",
122
+ "boosts",
123
+ )
124
+ _DOWN_WORDS = (
125
+ "decrease",
126
+ "decreases",
127
+ "reduce",
128
+ "reduces",
129
+ "cut",
130
+ "cuts",
131
+ "trim",
132
+ "trims",
133
+ "lower",
134
+ "drop",
135
+ "drops",
136
+ "down",
137
+ "fall",
138
+ "falls",
139
+ )
140
+ # Word-boundary matching is required here: naive substring checks on these short
141
+ # words false-positive constantly in ordinary English (e.g. "up" inside "supplier"
142
+ # or "group", "down" inside "downside", "cut" inside "circuit").
143
+ _UP_RE = re.compile(r"\b(?:" + "|".join(_UP_WORDS) + r")\b")
144
+ _DOWN_RE = re.compile(r"\b(?:" + "|".join(_DOWN_WORDS) + r")\b")
145
+
146
+ # Requests that are clear but cannot be expressed as a FinIR model mutation --
147
+ # distinct from "ambiguous" (right domain, missing quantity). See
148
+ # docs/intent-contract.md section 7 / docs/huggingface-intent-handoff.md section 7.
149
+ _UNSUPPORTED_WORDS = (
150
+ "acquire",
151
+ "acquires",
152
+ "acquiring",
153
+ "acquired",
154
+ "acquisition",
155
+ "acquisitions",
156
+ "merge",
157
+ "merges",
158
+ "merging",
159
+ "merged",
160
+ "merger",
161
+ "mergers",
162
+ "hire",
163
+ "hires",
164
+ "hiring",
165
+ "hired",
166
+ "fire staff",
167
+ "fire employees",
168
+ "fired staff",
169
+ "fired employees",
170
+ "lay off",
171
+ "laying off",
172
+ "laid off",
173
+ "layoff",
174
+ "layoffs",
175
+ "ipo",
176
+ "go public",
177
+ "going public",
178
+ "buy back",
179
+ "buying back",
180
+ "bought back",
181
+ "buyback",
182
+ "buybacks",
183
+ "share buyback",
184
+ "litigation",
185
+ "lawsuit",
186
+ "lawsuits",
187
+ "sue",
188
+ "sues",
189
+ "sued",
190
+ "suing",
191
+ "bankrupt",
192
+ "bankruptcy",
193
+ "restructure the board",
194
+ "new ceo",
195
+ )
196
+ # Word-boundary matching: a naive substring check false-positives on ordinary
197
+ # English containing these as a fragment (e.g. "merge" inside "emergency", "sue"
198
+ # inside "issue", "fire" inside "fired up about revenue growth" is fine but "fire"
199
+ # bare must not match inside e.g. "firewall" or "fireside").
200
+ _UNSUPPORTED_RE = re.compile(r"\b(?:" + "|".join(re.escape(w) for w in _UNSUPPORTED_WORDS) + r")\b")
201
+
202
+ # Multi-word unsupported phrases where a number or a couple of filler words can sit
203
+ # between the trigger words, so a flat word list can't catch them (e.g. "fire 100
204
+ # employees", "take the company public"). Anchored to headcount/listing nouns so
205
+ # they don't false-positive on ordinary English ("fire up the sales pipeline" has no
206
+ # employees/staff/... noun, so it does not match).
207
+ _UNSUPPORTED_PATTERNS = (
208
+ re.compile(
209
+ r"\bfir(?:e|es|ed|ing)\b(?:\s+\w+){0,3}\s+(?:employees|workers|staff|people|headcount)\b"
210
+ ),
211
+ re.compile(r"\b(?:take|takes|taking|took)\b(?:\s+\w+){0,3}\s+public\b"),
212
+ re.compile(r"\bgo(?:es|ing)?\s+public\b"),
213
+ re.compile(r"\bpublic\s+(?:offering|listing)\b"),
214
+ re.compile(r"\blist(?:s|ed|ing)?\s+on\s+the\s+(?:stock\s+)?exchange\b"),
215
+ re.compile(r"\b(?:cut|cuts|reduce|reduces|slash|slashes)\s+headcount\b"),
216
+ )
217
+
218
+ # Spelled-out cardinal numbers (deterministic, exact parsing of an unambiguous
219
+ # number phrase -- not a guess). Kept small and targeted: only the words needed to
220
+ # resolve a spelled-out number, never a general vocabulary.
221
+ _NUM_WORDS: dict[str, int] = {
222
+ "zero": 0,
223
+ "one": 1,
224
+ "two": 2,
225
+ "three": 3,
226
+ "four": 4,
227
+ "five": 5,
228
+ "six": 6,
229
+ "seven": 7,
230
+ "eight": 8,
231
+ "nine": 9,
232
+ "ten": 10,
233
+ "eleven": 11,
234
+ "twelve": 12,
235
+ "thirteen": 13,
236
+ "fourteen": 14,
237
+ "fifteen": 15,
238
+ "sixteen": 16,
239
+ "seventeen": 17,
240
+ "eighteen": 18,
241
+ "nineteen": 19,
242
+ "twenty": 20,
243
+ "thirty": 30,
244
+ "forty": 40,
245
+ "fifty": 50,
246
+ "sixty": 60,
247
+ "seventy": 70,
248
+ "eighty": 80,
249
+ "ninety": 90,
250
+ }
251
+ _NUM_SCALES: dict[str, int] = {
252
+ "hundred": 100,
253
+ "thousand": 1_000,
254
+ "million": 1_000_000,
255
+ "billion": 1_000_000_000,
256
+ }
257
+ _NUM_WORD_ALT = "|".join(sorted({*_NUM_WORDS, *_NUM_SCALES}, key=len, reverse=True))
258
+
259
+ _PCT_RE = re.compile(r"([+-]?\d+(?:\.\d+)?)\s*(?:%|\bpercent\b)")
260
+ # Only a phrase built entirely from recognized number/scale words is captured --
261
+ # deliberately not a generic "any words before percent" grab, which would swallow
262
+ # unrelated leading words (e.g. "up" in "bump ... up five percent").
263
+ _SPELLED_PCT_RE = re.compile(
264
+ rf"\b((?:{_NUM_WORD_ALT})(?:[ -](?:{_NUM_WORD_ALT}))*)\s+percent\b", re.I
265
+ )
266
+ _SPELLED_MONEY_RE = re.compile(
267
+ rf"^((?:{_NUM_WORD_ALT})(?:[ -](?:{_NUM_WORD_ALT}))*)\s*(rand|rands|dollars?|zar|usd)?\s*\.?\s*$",
268
+ re.I,
269
+ )
270
+ _DAYS_RANGE_RE = re.compile(r"\b(\d+)\s*(?:to|->|→)\s*(\d+)\s*days?\b")
271
+ _DAYS_TO_RE = re.compile(r"\bto\s+(\d+)\s*days?\b")
272
+ _BY_RE = re.compile(r"\bby\s+(.+)$")
273
+ _TO_RE = re.compile(r"\bto\s+(.+)$")
274
+ _MONEY_AMOUNT_RE = re.compile(r"(r|zar|usd|\$)?\s*([\d]+(?:\.\d+)?)\s*(zar|usd)?", re.I)
275
+ _RANGE_RE = re.compile(
276
+ r"\b(?:range|sweep|grid|scan|explore)\b.*?\b(?:from|between)\b\s*(?:r|zar|usd|\$)?\s*([\d.]+)"
277
+ r"\s*(?:zar|usd)?\s*\b(?:to|and)\b\s*(?:r|zar|usd|\$)?\s*([\d.]+)\s*(?:zar|usd)?.*?\b(\d+)\s*(?:steps|points)\b",
278
+ re.I | re.S,
279
+ )
280
+ _SCENARIO_SPLIT_RE = re.compile(r"([A-Za-z][A-Za-z ]{0,24}?)\s+scenario\s*:\s*", re.I)
281
+ _NO_CHANGE_BODY = {
282
+ "no change",
283
+ "no changes",
284
+ "none",
285
+ "base",
286
+ "unchanged",
287
+ "the base case",
288
+ "base case",
289
+ }
290
+
291
+
292
+ def _strip_thousands(text: str) -> str:
293
+ """Remove thousand-separator commas from numbers (``5,000,000`` -> ``5000000``)."""
294
+ return re.sub(r"(?<=\d),(?=\d{3}\b)", "", text)
295
+
296
+
297
+ def _amount(raw: str) -> float:
298
+ return float(re.sub(r"[^\d.]", "", raw))
299
+
300
+
301
+ def _currency(*fragments: str | None) -> str | None:
302
+ for frag in fragments:
303
+ if not frag:
304
+ continue
305
+ low = frag.strip().lower()
306
+ if low in ("r", "zar", "rand", "rands"):
307
+ return "ZAR"
308
+ if low in ("$", "usd", "dollar", "dollars"):
309
+ return "USD"
310
+ return None
311
+
312
+
313
+ def _words_to_number(phrase: str) -> float | None:
314
+ """Convert a spelled-out cardinal number phrase (e.g. 'five million') to a
315
+ float. Deterministic, exact parsing of an unambiguous number -- not a guess.
316
+ Returns None if any token isn't a recognized number/scale word.
317
+ """
318
+ tokens = phrase.strip().lower().replace("-", " ").split()
319
+ if not tokens:
320
+ return None
321
+ total = 0
322
+ current = 0
323
+ for t in tokens:
324
+ if t == "and":
325
+ continue
326
+ if t in _NUM_WORDS:
327
+ current += _NUM_WORDS[t]
328
+ elif t in _NUM_SCALES:
329
+ scale = _NUM_SCALES[t]
330
+ if scale == 100:
331
+ current = (current or 1) * scale
332
+ else:
333
+ total += (current or 1) * scale
334
+ current = 0
335
+ else:
336
+ return None
337
+ return float(total + current)
338
+
339
+
340
+ def _direction_sign(low: str) -> float:
341
+ up = bool(_UP_RE.search(low))
342
+ down = bool(_DOWN_RE.search(low))
343
+ return -1.0 if (down and not up) else 1.0
344
+
345
+
346
+ def _find_target(low: str) -> str | None:
347
+ for alias in _SORTED_ALIASES:
348
+ if re.search(r"\b" + re.escape(alias) + r"\b", low):
349
+ return _TARGET_ALIASES[alias]
350
+ return None
351
+
352
+
353
+ def _split_clauses(text: str) -> list[str]:
354
+ protected = _strip_thousands(text)
355
+ parts = re.split(r"\s*;\s*|\s*,\s*(?=[A-Za-z])|\s+\band\b\s+", protected)
356
+ clauses = []
357
+ for p in parts:
358
+ p = re.sub(r"^(and|then|also)\s+", "", p.strip(), flags=re.I).strip(" .")
359
+ if p:
360
+ clauses.append(p)
361
+ return clauses
362
+
363
+
364
+ def _parse_clause(clause: str) -> tuple[dict[str, Any] | None, str | None]:
365
+ """Parse one clause. Returns (operation, note); note is set only on failure."""
366
+ low = clause.lower()
367
+ target = _find_target(low)
368
+ if target is None:
369
+ return None, None
370
+ kind = _TARGET_KIND.get(target, "money")
371
+
372
+ m = _DAYS_RANGE_RE.search(low)
373
+ if m:
374
+ return {
375
+ "operation": "set",
376
+ "target": target,
377
+ "value": _amount(m.group(2)),
378
+ "unit": "days",
379
+ }, None
380
+
381
+ m = _DAYS_TO_RE.search(low)
382
+ if m:
383
+ return {
384
+ "operation": "set",
385
+ "target": target,
386
+ "value": _amount(m.group(1)),
387
+ "unit": "days",
388
+ }, None
389
+
390
+ # "set/reduce/... TARGET to N%" is an absolute set to that percentage level
391
+ # (new = value), not a relative_change -- must be checked before the generic
392
+ # relative-change percentage rule below.
393
+ m_to = _TO_RE.search(low)
394
+ if m_to:
395
+ pct = _PCT_RE.match(m_to.group(1).strip())
396
+ if pct and kind == "percentage":
397
+ return (
398
+ {
399
+ "operation": "set",
400
+ "target": target,
401
+ "value": _amount(pct.group(1)) / 100.0,
402
+ "unit": "percentage",
403
+ },
404
+ None,
405
+ )
406
+ if pct:
407
+ # "set/change <non-percentage target> to N%" (e.g. "set opex to 45%") is
408
+ # not a coherent instruction for this target's type -- refuse rather
409
+ # than silently reinterpreting "to" as "by" (relative_change), which
410
+ # would invent a meaning the user did not state.
411
+ return None, "target_no_value"
412
+
413
+ m = _PCT_RE.search(low)
414
+ if m:
415
+ value = _direction_sign(low) * _amount(m.group(1)) / 100.0
416
+ return {"operation": "relative_change", "target": target, "value": value}, None
417
+
418
+ m = _SPELLED_PCT_RE.search(low)
419
+ if m:
420
+ if m_to and _SPELLED_PCT_RE.match(m_to.group(1).strip()):
421
+ # Same "set X to <percent>" guard as the digit case above -- a
422
+ # spelled-out number must not bypass it.
423
+ return None, "target_no_value"
424
+ num = _words_to_number(m.group(1))
425
+ if num is not None:
426
+ value = _direction_sign(low) * num / 100.0
427
+ return {"operation": "relative_change", "target": target, "value": value}, None
428
+
429
+ m = _BY_RE.search(low)
430
+ if m and kind == "money":
431
+ amt_match = _MONEY_AMOUNT_RE.search(m.group(1))
432
+ if amt_match and amt_match.group(2):
433
+ amt = _amount(amt_match.group(2))
434
+ sign = -1.0 if _direction_sign(low) < 0 else 1.0
435
+ op: dict[str, Any] = {
436
+ "operation": "absolute_change",
437
+ "target": target,
438
+ "value": sign * amt,
439
+ }
440
+ ccy = _currency(amt_match.group(1), amt_match.group(3))
441
+ if ccy:
442
+ op["currency"] = ccy
443
+ return op, None
444
+ spelled = _SPELLED_MONEY_RE.match(m.group(1).strip())
445
+ if spelled:
446
+ num = _words_to_number(spelled.group(1))
447
+ if num is not None:
448
+ sign = -1.0 if _direction_sign(low) < 0 else 1.0
449
+ op = {"operation": "absolute_change", "target": target, "value": sign * num}
450
+ ccy = _currency(spelled.group(2))
451
+ if ccy:
452
+ op["currency"] = ccy
453
+ return op, None
454
+
455
+ if m_to and kind in ("money", "days"):
456
+ amt_match = _MONEY_AMOUNT_RE.search(m_to.group(1))
457
+ if amt_match and amt_match.group(2):
458
+ amt = _amount(amt_match.group(2))
459
+ op = {"operation": "set", "target": target, "value": amt}
460
+ if kind == "money":
461
+ ccy = _currency(amt_match.group(1), amt_match.group(3))
462
+ if ccy:
463
+ op["currency"] = ccy
464
+ else: # days, without the word "days" already matched above
465
+ op["unit"] = "days"
466
+ return op, None
467
+ # kind == "quantity": a plain numeric "set ... to N" is left unhandled (unit
468
+ # intentionally never guessed for quantity targets -- see MODEL_CARD.md "known
469
+ # limitations": the runtime's semantic validator only accepts unit == 'scalar'
470
+ # or no unit for Quantity-typed targets today).
471
+
472
+ return None, "target_no_value"
473
+
474
+
475
+ def _envelope(
476
+ status: str, *, operations: list[dict[str, Any]] | None = None, reason: str | None = None
477
+ ) -> dict[str, Any]:
478
+ env: dict[str, Any] = {
479
+ "schema_version": SCHEMA_VERSION,
480
+ "status": status,
481
+ "operations": operations or [],
482
+ }
483
+ if reason is not None:
484
+ env["reason"] = reason
485
+ return env
486
+
487
+
488
+ def _is_unsupported(low: str) -> bool:
489
+ if _UNSUPPORTED_RE.search(low):
490
+ return True
491
+ return any(pat.search(low) for pat in _UNSUPPORTED_PATTERNS)
492
+
493
+
494
+ def _try_parse_scenarios(text: str) -> dict[str, Any] | None:
495
+ parts = _SCENARIO_SPLIT_RE.split(text)
496
+ if len(parts) < 3:
497
+ return None
498
+ preamble = parts[0].strip(" .")
499
+ if preamble and len(preamble.split()) > 8:
500
+ return None
501
+
502
+ scenarios: list[dict[str, Any]] = []
503
+ for i in range(1, len(parts), 2):
504
+ name = re.sub(r"\s+", "_", parts[i].strip().lower())
505
+ body = parts[i + 1].strip(" .") if i + 1 < len(parts) else ""
506
+ if not body or body.lower() in _NO_CHANGE_BODY:
507
+ scenarios.append({"name": name, "operations": []})
508
+ continue
509
+ ops: list[dict[str, Any]] = []
510
+ unparsed = False
511
+ for clause in _split_clauses(body):
512
+ op, _note = _parse_clause(clause)
513
+ if op is not None:
514
+ if op["operation"] == "range":
515
+ return None # range is never valid inside a scenario
516
+ ops.append(op)
517
+ else:
518
+ unparsed = True
519
+ if unparsed and not ops:
520
+ return None # can't confidently build this scenario -> fall through to ambiguous
521
+ targets = [o["target"] for o in ops]
522
+ if len(targets) != len(set(targets)):
523
+ return None
524
+ scenarios.append({"name": name, "operations": ops})
525
+
526
+ if len(scenarios) < 2:
527
+ return None
528
+ return {"schema_version": SCHEMA_VERSION, "status": "valid", "scenarios": scenarios}
529
+
530
+
531
+ def compile_intent(text: str) -> dict[str, Any]:
532
+ """Compile one natural-language financial instruction into a canonical envelope.
533
+
534
+ Always returns a dict that structurally validates against
535
+ ``finir.intent.json_schema()`` (verify with
536
+ ``finir.intent.FinIRIntent.from_obj`` before executing).
537
+ """
538
+ scenario_env = _try_parse_scenarios(text)
539
+ if scenario_env is not None:
540
+ return scenario_env
541
+
542
+ low = text.lower()
543
+ if _is_unsupported(low):
544
+ return _envelope(
545
+ "unsupported",
546
+ reason=f"request cannot be represented as a FinIR model mutation: {text!r}",
547
+ )
548
+
549
+ range_match = _RANGE_RE.search(_strip_thousands(low))
550
+ if range_match:
551
+ target = _find_target(low)
552
+ if target is not None:
553
+ return _envelope(
554
+ "valid",
555
+ operations=[
556
+ {
557
+ "operation": "range",
558
+ "target": target,
559
+ "min": _amount(range_match.group(1)),
560
+ "max": _amount(range_match.group(2)),
561
+ "steps": int(range_match.group(3)),
562
+ }
563
+ ],
564
+ )
565
+
566
+ ops: list[dict[str, Any]] = []
567
+ saw_target_without_value = False
568
+ for clause in _split_clauses(text):
569
+ op, note = _parse_clause(clause)
570
+ if op is not None:
571
+ ops.append(op)
572
+ elif note == "target_no_value":
573
+ saw_target_without_value = True
574
+
575
+ if not ops:
576
+ if saw_target_without_value:
577
+ return _envelope(
578
+ "ambiguous",
579
+ reason="a financial target was identified but no quantitative change was specified",
580
+ )
581
+ return _envelope(
582
+ "ambiguous", reason="no financial target or quantitative change was identified"
583
+ )
584
+
585
+ targets = [o["target"] for o in ops]
586
+ if len(targets) != len(set(targets)):
587
+ return _envelope(
588
+ "ambiguous",
589
+ reason="conflicting operations on the same target were requested; cannot resolve unambiguously",
590
+ )
591
+
592
+ return _envelope("valid", operations=ops)
593
+
594
+
595
+ class BaselineIntentCompiler(IntentCompiler):
596
+ """The FinIR-Intent baseline, implementing the core :class:`IntentCompiler` seam.
597
+
598
+ Deterministic and fully offline -- no external LLM/API dependency. Emits exactly
599
+ the canonical envelope; validation and execution are left entirely to
600
+ ``finir.intent`` / ``FinancialModel.apply_intent`` (never duplicated here).
601
+ """
602
+
603
+ def compile(self, text: str) -> dict[str, Any]:
604
+ return compile_intent(text)
src/finir_intent/reference_model.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A small demo :class:`~finir.FinancialModel` spanning every FinIR-IntentBench target.
2
+
3
+ This is fixture data for the Hugging Face workstream's end-to-end proof and the
4
+ Space demo -- not a real company's financials. It exists so every target the
5
+ baseline compiler can name (see ``finir_intent.baseline._TARGET_KIND``) is a real
6
+ input the runtime can execute against. Built entirely from the public
7
+ ``finir.FinancialModel`` API; it adds no new runtime behavior and duplicates no
8
+ execution logic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from finir import FinancialModel
14
+
15
+
16
+ def build_reference_model() -> FinancialModel:
17
+ m = FinancialModel(name="finir_intent_reference")
18
+ m.input("revenue", 500_000_000, currency="ZAR")
19
+ m.input("cogs", 300_000_000, currency="ZAR")
20
+ m.input("opex", 120_000_000, currency="ZAR")
21
+ m.input("payment_terms", 30, type="days")
22
+ m.input("accounts_payable", 40_000_000, currency="ZAR")
23
+ m.input("inventory", 50_000_000, currency="ZAR")
24
+ m.input("capex", 60_000_000, currency="ZAR")
25
+ m.input("debt", 200_000_000, currency="ZAR")
26
+ m.input("interest_rate", 0.09, type="percentage")
27
+ m.input("cash", 80_000_000, currency="ZAR")
28
+ m.input("price", 1_250, currency="ZAR")
29
+ m.input("volume", 400_000, type="quantity[units]")
30
+
31
+ m.define("gross_profit", "revenue - cogs")
32
+ m.define("gross_margin", "gross_profit / revenue", output=True)
33
+ m.define("ebitda", "gross_profit - opex", output=True)
34
+ m.define("interest_expense", "debt * interest_rate", output=True)
35
+ m.define("net_cash_position", "cash - capex", output=True)
36
+ return m