Publish FinIR-Intent 0.1.0 (deterministic structured-output baseline)
Browse files- README.md +238 -0
- pyproject.toml +58 -0
- src/finir_intent/__init__.py +25 -0
- src/finir_intent/_version.py +12 -0
- src/finir_intent/baseline.py +604 -0
- src/finir_intent/reference_model.py +36 -0
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
|