Parses Indian informal number/currency shorthand — Hinglish (romanised
Hindi), Devanagari Hindi, Marathi, Gujarati, and Indian-English amount
phrases like sava lakh, dedh crore, डेढ़ लाख, सवा करोड़, દોઢ લાખ,
2.5L, 20k, 2-3 lakh — into a clean numeric value, with the span,
unit, currency, and confidence that produced it. Mixed-script input
(Latin, Devanagari and Gujarati in the same string, e.g. "budget 2 લાખ hai") is supported: currency and unit detection scan every
script's marker lists.
A small char-level CNN tags each character of the input with a BIO span
label and a semantic token class (digit, prefix word like "sava"/"dedh",
cardinal number word, scale unit like lakh/crore/hazaar). A deterministic
arithmetic core then turns that class sequence into a number — the model
never predicts the value directly, so the arithmetic can't drift from the
grammar (prefix semantics, additive descending units, multiplicative
ascending units like das hazaar crore = 10,000 × 1 crore = 1e11, etc).
Ships with a small (47.2 KB gzipped, 80.8 KB raw dist/index.js)
int8-quantized default model inlined in the package — import { parse } from "gpu-sankhya" works with no network fetch. Zero runtime
dependencies.
Model training lives in python/ (a separate, actively-trained
component); this package is the runtime that loads its exported weights.
See python/README.md if you want to train your own weights and load
them via createParser({ weights }) instead of the bundled default.
See CHANGELOG.md for what changed in each release.
npm install gpu-sankhyaimport { parse, parseBatch, createParser } from "gpu-sankhya";
parse("sava lakh");
// [{
// span: "sava lakh", start: 0, end: 9,
// value: 125000, unit: "lakh", currency: null,
// confidence: 0.97, rawConfidence: 0.95,
// classes: ["PFX_SAVA", "SEP", "UNIT_LAKH"],
// verified: true, tokens: [["PFX_SAVA","sava"],["SEP"," "],["UNIT_LAKH","lakh"]]
// }]
// strict mode: only verified spans come back -- "the right number or
// nothing" (see "Correct or abstains" below).
parse("savaa lakhhh", { strict: true }); // []
parse("savaa lakhhh"); // [{ value: 125000, verified: false, ... }]
parse("mera budget paune do lakh tak ka hai");
// [{ span: "paune do lakh", value: 175000, unit: "lakh", ... }]
parse("2.5L");
// [{ span: "2.5L", value: 250000, unit: "lakh", ... }]
parse("20k logon ne attend kiya");
// [{ span: "20k", value: 20000, unit: "hazaar", ... }]
parse("2-3 lakh");
// [{ span: "2-3 lakh", value: 200000, range: [200000, 300000], unit: "lakh", ... }]
parse("das hazaar crore");
// [{ span: "das hazaar crore", value: 100000000000, unit: "crore", ... }]
parse("sawaa laakh ka budget hai");
// [{ span: "sawaa laakh", value: 125000, unit: "lakh", ... }] -- spelling
// variance ("sawaa"/"sava", "laakh"/"lakh") is part of the training data,
// not special-cased.
// Devanagari input follows the same grammar/arithmetic as Hinglish (the
// core is language-independent -- see How it works); the expected output
// shape is identical, e.g.:
parse("डेढ़ लाख");
// [{ span: "डेढ़ लाख", value: 150000, unit: "lakh", ... }]
parse("सवा दो करोड़ का फ्लैट");
// [{ span: "सवा दो करोड़", value: 22500000, unit: "crore", ... }]
parse("बजट साढ़े तीन लाख है");
// [{ span: "साढ़े तीन लाख", value: 350000, unit: "lakh", ... }]
// NOTE: the correctness of these Devanagari examples depends on the
// bundled weights being trained on the hi_deva pack (see python/README.md);
// the runtime-side normalization/decoding/currency support is in place
// independent of which weights are loaded (createParser({ weights })).
// batch (uses WebGPU automatically for large batches in a browser, else CPU)
const results = await parseBatch(["sava lakh", "dedh crore", "..."]);
// custom / newer trained weights (see python/README.md to train your own)
const parser = createParser({ weights: myWeightsJson, backend: "cpu" });
parser.parse("paune do lakh");interface Sankhya {
span: string; // exact source substring
start: number; end: number;
value: number; // resolved value; low end for ranges
range?: [number, number];// present only for ranges
unit: "sau"|"hazaar"|"lakh"|"crore"|"million"|"billion"|"arab"|"kharab"|null;
currency: "INR"|null; // adjacent marker detected outside the span
confidence: number; // CALIBRATED confidence: read it as a precision --
// see "Calibrated confidence" below
rawConfidence: number; // the model's uncalibrated score: mean of the max
// span-tag softmax prob over the span's characters
classes: string[]; // normalised token classes, e.g. ["PFX_DHAI","UNIT_LAKH"]
verified: boolean; // true iff every token in `tokens` is independently
// justified by the lexicon -- see "Correct or abstains"
tokens: Array<[string, string]>; // (class, text) tokens the span decoded to,
// in order -- for explainability and verification
}confidence is calibrated, not a raw softmax number. The model's own score
(rawConfidence, the mean over the span's characters of the larger of the
B/I tag probabilities) orders spans sensibly but is squashed into a narrow
band — 99% of predicted spans score above 0.9 — so thresholding it directly
tells you very little. python -m sankhya.calibrate runs the shipped
checkpoint over 100,000 freshly generated examples (seed 13, never used for
training; 104,805 predicted spans), records for each predicted span whether
its value was actually right, and fits a monotone isotonic
(pool-adjacent-violators) map from raw score to empirical precision. The 24
fitted breakpoints ship as src/data/calibration.json and are applied
identically by the JS and Python runtimes, so among spans the model
reports at 0.8, about 80% have the right value on held-out synthetic
data, and 0.95 means about 95%. The full raw score is still there as
rawConfidence, and the decoder's internal 0.5 drop gate still runs on the
raw score, unchanged.
On the four hand-written gold sets (748 examples, 607 spans) — never fitted
on — the reliability broadly holds but is a little optimistic in the middle
of the range: of the 556 gold spans reported between 0.95 and 0.98, 97.5%
had the right value; of the 36 reported between 0.90 and 0.95, 83% did; of
the 11 reported below 0.90, 82% did. In practice the useful lever is
minConfidence around 0.9, which on gold trades 1.5 points of coverage
(0.962 → 0.949) for a small gain in value accuracy (0.967 → 0.968).
Because the top of the fitted map only reaches about 0.98 for typical
spans, a threshold of 0.99 rejects essentially everything — treat 0.9-0.97
as the working range, and strict: true (the verified tier) as the
stronger guarantee when you want "the right number or nothing".
The CNN's job is only to propose a span and its token classes; a
deterministic core (lexicon lookups + the arithmetic in core.ts) computes
the value. A span is verified when every one of its tokens is
independently justified by the lexicon — a digit run is all digits, a
separator is whitespace, and every other token's exact (lowercased) text
is the surface form the lexicon lists for that class (or that surface minus
one case ending the language pack declares, so inflected Marathi/Gujarati
number words like "लाखांचं" and "કરોડનો" verify as their head unit, while a
surface that is a full lexicon form in its own right is never stripped) — so a verified
span's value is a pure function of the lexicon and the arithmetic core,
neither of which is a black box: both are unit-tested (test/verify.test.ts,
test/core.test.ts) independent of the model. strict: true uses this to
give you the guarantee "the right number or nothing": it drops every span
the model tagged but the lexicon didn't corroborate, rather than risk
returning a value for a spelling the model merely guessed at.
parse(text, opts?) => Sankhya[]— synchronous, always CPU. Use this for one-off strings; there's no async overhead.parseBatch(texts, opts?) => Promise<Sankhya[][]>— batched parse. Runs on CPU by default. Pass{ backend: "webgpu" }to force WebGPU, or{ backend: "auto" }to use WebGPU automatically when it's available and the batch has at least 32 texts (otherwise CPU, since GPU dispatch overhead dominates for small batches).{ strict: true }(onparse/parseBatch/Parser.parse/Parser.parseBatch, CPU and WebGPU paths alike) — drop any span that isn'tverified, so you only ever get back a span whose value is provably a pure function of the lexicon + arithmetic core. Defaultfalse(unverified spans are still returned, withverified: false).{ minConfidence: number }(onparse/parseBatch/Parser.parse/Parser.parseBatch) — drop spans whose calibratedconfidenceis below this, applied after every other gate includingstrict. Default0(keep everything). See "Calibrated confidence" above for what the number means and what thresholds are worth setting; the decoder's own fixed 0.5 gate onrawConfidenceis separate and always applies.createParser({ weights?, backend? })— build aParserinstance around a custom weights JSON (float or int8 form, as written bypython/sankhya/export.py), instead of the bundled default model. Seepython/README.mdfor how to train and export your own weights.isWebGPUAvailable()— true ifnavigator.gpuexists in the current environment. Cheap and synchronous, but doesn't guarantee a usable adapter (e.g. headless browsers without GPU access).probeWebGPU() => Promise<boolean>— authoritative async check: resolves false immediately ifnavigator.gpuis missing, otherwise awaitsrequestAdapter()and resolves to whether an adapter was actually obtained. Result is cached, so repeated calls only probe once.parseBatch's"auto"backend uses this (notisWebGPUAvailable()) to decide whether to try the GPU path.- Non-string input:
parse,parseBatch, andinspectall tolerate a non-string argument (null,undefined, a number, object, array, boolean, ...) by treating it as no input rather than throwing —parse(null)returns[]instead of crashing. inspect(text) => Inspection— synchronous CPU inspection of the raw model output before decode/repair:{ text, chars: [{ch, bio, bioProb, cls, clsId}], spans, ms }, one entry per character with its argmax BIO tag, BIO softmax probability, and predicted class. Useful for debugging misclassifications or building a "what did the model actually see" view (this is what powers the demo's per-character panel);parse()'s decoded spans are the thing to use for normal consumption.modelInfo() => ModelInfo— info about the currently loaded default model:{ version, params, channels, embedDim, vocab, classes, layers: [{k, dilation, residual}] }.CLASSES— the exported array of semantic token class names (the same strings that appear inSankhya.classesandInspection.chars[].cls).normalizeText(text) => string— the exact normalizationparse/parseBatchapply before char-encoding (Unicode NFC, then Devanagari digits U+0966-U+096F mapped 1:1 to ASCII"0"-"9", then lowercasing), exposed so callers can reproduce the same offsets.parsereturnsstart/endas indices intonormalizeText(text), not the raw input — for the overwhelming majority of input, which already arrives in NFC form, normalization never changes the string's length, so offsets are unaffected; callnormalizeTextyourself first if you need to be sure for non-NFC input. Thespanfield is a substring of your original input when lengths match, and of the normalized string otherwise.
- CPU (default everywhere): a plain typed-array forward pass mirroring the Python reference implementation exactly, tuned for throughput (preallocated buffers, channels-outer loop order, no per-character allocation). Runs in Node and any browser.
- WebGPU: the same forward pass as WGSL compute shaders (embedding
gather, one dispatch per conv1d+ReLU layer, two head matmuls), used only
through
parseBatchfor large batches. Device/shader setup is lazy — importing the package never touchesnavigator.gpu, so it's safe to import in Node or SSR. Falls back to CPU per-text for inputs longer than the model's 128-char window (the sliding-window path used byparseis CPU-only for now).
One inference is small — a 5-layer, 48-channel dilated/residual char
CNN, 40,503 parameters — and parse() runs in about 3.6 ms p50 (60-char
input) in plain JS; parseBatch on CPU averages about 4.0 ms/string over
500 strings. A WebGPU dispatch has fixed overhead of a few milliseconds
(device/pipeline setup, buffer upload, queue submit, readback), which
dwarfs that per-string cost. So the GPU only pays off once you're
amortizing that overhead over a batch — hundreds of strings at once —
which is exactly what parseBatch does with backend: "auto"/"webgpu";
parse() stays CPU-only and synchronous on purpose.
The WebGPU path has been verified on a real GPU in desktop Chrome with
the shipped v2 model (residual layers included): on the demo page's
batch benchmark (500 varied strings) it matches the CPU backend
span-for-span (500/500) and runs about 1.9x faster (CPU 1.66 ms/string,
WebGPU 0.88 ms/string). The demo's benchmark re-runs the CPU/WebGPU
comparison on every click, so any regression shows up as a mismatch
count rather than a silent wrong answer.
The bundled default model (arch v2): 40,503 parameters, 5 conv layers
over 16-dim char embeddings — a plain kernel-5 layer, then four residual
kernel-3 layers with dilations 1/2/4/8 (y = relu(conv(x)) + x), 48
channels, ±17-character receptive field — over a 170-character vocab
(union of the hi_latn, hi_deva, mr_deva, and gu_gujr packs), 120
output classes. Trained 20 epochs on 200,000 synthetic examples
generated from all four language packs' grammars, mixed
0.32/0.26/0.21/0.21 with a 10% cross-pack share, plus out-of-vocab "unk
noise" augmentation so the model has actually seen <unk> characters
(emoji, CJK, Cyrillic, other symbols) during training (see
python/README.md). An older v1 preset (the previously shipped
4-layer, non-residual, 32-channel stack) is still loadable by both the
Python and JS runtimes for anyone using older exported weights.
On synthetic validation data (drawn from the same generator/templates as training): 0.9669 value accuracy. This number is optimistic — it's testing the model on its own distribution.
On four hand-written gold sets, written independently of the generator —
python/tests/gold.jsonl (romanised Hindi, 227 sentences / 199 spans),
python/tests/gold_deva.jsonl (Devanagari Hindi, 186 sentences / 155
spans), python/tests/gold_mr.jsonl (Devanagari Marathi, 173
sentences / 135 spans), and python/tests/gold_gu.jsonl (Gujarati, 162
sentences / 118 spans) — evaluated against the shipped int8-quantized
weights:
| gold set | examples | spans | value accuracy | span precision | span recall | span F1 |
|---|---|---|---|---|---|---|
| gold.jsonl (romanised) | 227 | 199 | 0.9598 | 0.9550 | 0.9598 | 0.9574 |
| gold_deva.jsonl (Devanagari) | 186 | 155 | 0.9677 | 0.9868 | 0.9677 | 0.9772 |
| gold_mr.jsonl (Marathi) | 173 | 135 | 0.9852 | 0.9852 | 0.9852 | 0.9852 |
| gold_gu.jsonl (Gujarati) | 162 | 118 | 0.9322 | 0.9658 | 0.9576 | 0.9617 |
| combined | 748 | 607 | 0.9621 | 0.9719 | 0.9671 | 0.9694 |
| strict mode (verified spans only) | 748 | 506 covered (0.9094) | 1.0000 | — | — | — |
Negatives (zero-gold-span examples, 170 total): 0 false positives. Miss summary: missed 6, spurious 0, wrong value 3, wrong boundary 14. Per-category value accuracy: digits 0.972, words 0.959, prefix 0.981, range 0.959, currency 0.966, multi_unit 0.964, symbol_unit 1.0, mixed_script 1.0, long 0.875.
For comparison, the previous shipped weights (0.5.0, three languages, 586 examples) scored 0.9548 (Hinglish) / 0.9613 (Devanagari) / 0.9778 (Marathi) / 0.9632 combined value acc, strict coverage 0.873 at 1.000 value accuracy. This round also ran a 198-case hand-written edge-case probe across 12 categories (whitespace/short input, long input, Unicode, numeric forms, prefix semantics, compound units, currency, negatives, multi-span strings, noise/typos, Devanagari equivalents, API contract) targeting decoder and boundary bugs rather than the gold distribution itself; the fixes below took it from 146 pass / 17 spurious / 8 wrong value / 7 crashes to 179 pass / 0 crashes / 5 spurious / 1 wrong value (remaining misses are mostly boundary/range edge cases, tracked below).
The gold numbers are the ones to trust. Known miss categories, in rough order of frequency:
- wrong span boundaries on multi-span/range/connector phrases (the largest single category this round)
- wrong value on a handful of prefix/compound constructs
- unusual typos the noise model doesn't cover (e.g. "croer" for "crore")
- long multi-term/mixed-numeral constructs ("three n half lakh", "50M")
- occasional spurious spans triggered by unfamiliar words near number-ish context
Reproduce these numbers yourself with
python -m sankhya.eval_gold --gold tests/gold.jsonl tests/gold_deva.jsonl tests/gold_mr.jsonl tests/gold_gu.jsonl --weights-json src/data/default-weights.json --int8
from python/ (see python/README.md).
- The input string is normalized (
normalizeText: Unicode NFC, then Devanagari digits U+0966-U+096F mapped to ASCII"0"-"9", then lowercased — byte-identical to the Python reference) and char-encoded against the model's vocab (unknown chars map to<unk>). - A dilated/residual conv1d stack (see Accuracy above) produces, per character, a 3-way BIO logit (O/B/I) and a class logit over the semantic token vocabulary (prefix words, cardinals, units, digits, separators, misc).
- Decoding turns those per-character predictions into spans and tokens (see Decoding below).
- The deterministic arithmetic core (
src/core.ts, mirrored 1:1 frompython/sankhya/core.pyand unit-tested directly intest/core.test.ts) evaluates each span's token sequence into a value: prefix semantics (sava = ×1.25, dedh = ×1.5, paune = subtract 1/4 from the next cardinal, ...), additive combination of descending units, multiplicative combination of ascending units, and range handling forX-Y unit/X se Y unitphrases.
Raw per-character BIO/class predictions are cleaned up before evaluation:
- Strict BIO decode: a span starts only at a
Btag; anIthat isn't preceded by an open span is treated asO. - BIO bridging: a single-character
Ogap inside what's otherwise a contiguous span is closed (handles a stray misclassified character without splitting the span in two). - Digit-run extension: a span is extended forward through a trailing run of digit characters it was cut short of.
- Class repair: within a span, per-character classes are smoothed by majority vote over character-type sub-runs (fixes a stray misclassified character inside an otherwise-consistent digit or letter run), plus a few punctuation-specific rules.
- Word integrity: a
UNIT_/PFX_/CARD_token must line up with the letter-word it sits in — a letters-run that's only partially meaningful (some characters fell back toO) has all of it retaggedO(e.g."10 km"), while a run legitimately split into several back-to-back meaningful sub-words is left alone (e.g."dedhlakh"=PFX_DEDH+UNIT_LAKH). A one-character symbol unit (k/K/l/L) is additionally only valid when the next character isn't a letter, so"20k logon"keeps itskbut"10 km"'skdoesn't survive alone either. Before that drop check runs, a BIO repair pass looks at the letter-word's raw per-character classes: if they unambiguously agree on one word class (trusting only direct evidence — a same-class run of 3+ characters, or a short stray adopting a qualifying neighbour's class, never a diffuse whole-run majority vote alone), the span's BIO is extended to cover the whole word instead of being left partial — this also re-merges a span that a low-confidence internalOgap had split in two (e.g."unnasi"raw-taggedB I I O I O, all six charactersCARD_79, becomes one span"unnasi"= 79 instead of dropping). - Bare-digits gate: a span whose only meaningful tokens are digits (no
unit/prefix/cardinal word at all) with no detected currency marker is
dropped only when it's short and ungrouped (≤4 digits, no comma — route
numbers, OTPs, years:
"1","route 66","OTP 4521","2024") or very long (≥10 digits — phone numbers:"9876543210"). Everything else is kept as a plain (unitless) amount —"15000","1,00,000","2,50,00,000"all parse;"₹75"/"Rs 2,50,000/-"/"1200 rupees"still parse withcurrency: "INR". The currency-marker look-ahead scans 8 characters past the amount for a trailing marker word (up from a shorter window that missed"1200 rupees"—"rupees"alone runs to 6 characters plus the leading space). - Lone-ambiguous-unit gate: a span whose only meaningful token is a
single
UNIT_KHARABorUNIT_MILLIONword, with no preceding number/prefix and no detected currency marker, is dropped —"kharab"(Hindi: usually "broken") and"mil"/"million"(often just an English loanword/fragment) are only genuine amount units when a number precedes them."unka washing machine kharab ho gaya hai"no longer parses;"das kharab","2 mil", and"kharab rupaye"(currency marker present) still parse. Other unit words ("lakh","hazaar") are unaffected.
The next six gates were added after the library was first measured on real
text (see python/data_wild/REPORT.md): on 400 hand-labelled sentences from
openly licensed corpora, one real negative in five produced a span, and one
in eleven produced a verified one. They exist for precision, and cost
nothing measurable on the synthetic gold.
- What counts as a span (the convention the rules below implement): an
amount expression contains a scale unit (word or symbol), or a
prefix word together with a number, or digits next to a currency
marker. A bare digit run with no unit and no currency is not a span
(
"109 वोट","1956"); a lone prefix is not a span ("आधा","ढाई साल","દોઢ સદી"); an indefinite plural is not a span ("हजारों","करोड़ों","લાખો"). A bare cardinal word is a span only when its surface is an exact lexicon form and not an ambiguous one —"पचास"(50) and"બાવીસ"(22) parse,"sath","so"and"एक"do not. Currency words stay outside the span; pack case endings stay inside it ("32 करोडचा","15 લાખનું"). - R11 — lone-prefix gate: a span whose only meaningful token is a
PFX_*is dropped unless a currency marker is beside it. A prefix scales something:"ढाई लाख"is 250,000, but"ढाई साल"is two and a half years and"sade hue tamatar"is rotten tomatoes. - R12 — ambiguous-form gate: each language pack declares
ambiguous_forms, lexicon surfaces (not classes) that are also ordinary words: hi_latnk,so,sath/saath,sade/sad,peti,mil,kharab,arab,char,bees,lac; hi_deva/mr_devaएक,अरब,खरब; gu_gujrએક,અરબ,ખરબ. A meaningful token is justified when it isDIGITSor a known lexicon form of its own class; if every justified token of a span is an ambiguous surface, the span is dropped (no currency marker)."so usne kya kiya?","sanyukt arab amirat","अरब संघ","અરબ સાગર"and a lone"एक"stop parsing;"ek arab","100 अरब","do peti","das kharab","unnis sau sath"and"bees lac pachees lac"still do, because each has one unambiguous number word holding it up."अरब रुपये"survives on the currency marker. - R13b — bare-cardinal gate: a span whose only meaningful token is a
spelled cardinal is kept only when its surface is an exact lexicon
form — no suffix stripping, no typo tolerance, since there is nothing
else in the span to corroborate it.
"aavesh","chaahie","barabar","इ"stop parsing. - R14 — blocked surfaces: every pack's
blocked_surfaceslist (already used by the generator to refuse a colliding surface) is now also honoured by the verifier and the decoder: such a surface never verifies as a number word and a span carrying one is dropped. Proper nouns built on number words are everywhere in real text and nowhere in the generator —"अण्णा हजारे","सवाई तुकोजीराव","अरबी समुद्र","અરબી સમુદ્ર". The head words ("हजार","सवा","अरब") are untouched. - R15 — suffix stripping is per pack: the verifier's case-ending strip
(
"लाखांचं"=लाख+ obliqueां+ endingचं) now only uses endings the same pack declares, and the stripped head must still be ≥ 3 characters. A union of every pack's morphology could let Marathi endings inflect a Hindi-only head into a number. - R16 — symbol units must be glued: a 1-2 character ambiguous symbol
unit (
k,l,m,b) counts only when written straight onto its digits."20k","2.5L"keep their unit;"1996 k"(the ke/ki clitic after a year) and"33 k. m."(kilometres) lose theirs and the span goes. Longer symbol forms (lac,cr,bn,LPA) are routinely written with a space and are untouched. - R17 — unknown unit words: when a span's
UNIT_*surface is neither a lexicon form of that class (after one declared case ending) nor within edit distance 1 of one, and the span has no independently justifiedCARD_*/PFX_*coefficient, the unit is a guess and the span is dropped."3 hours"→ 3,000,"25वे"(an ordinal) → 2,500,000,"1980ના"→ 1.98e8, and bare"chori"/"thought"/"oooh"/"खोड"stop parsing; the edit-distance tolerance keeps typo'd and noised unit words ("hazzar","करोड") working after digits. - Range-connector repair: a bare
-/–/—//between two amounts becomes aRANGEtag (spaces around it staySEP) when the left side can end an amount by itself and the right side can start a fresh one ("2 lakh/3 lakh","दो-तीन लाख") — but not when it's really one compound number, e.g. a prefix glued straight to a unit ("dedh-lakh"stays one span, not a range). - Word-connector span merge: when a whole connector WORD (e.g.
"se"/"से") between two separately BIO-decoded spans is itself taggedRANGEby the class head, the two spans are merged into oneRANGEspan (connector letters →RANGE, its flanking spaces →SEP) — fixes the BIO head splitting a range like"दो लाख से तीन लाख"or"5 hazaar se 8 hazaar"into two separate amounts even though the class head correctly tagged the connector word. - Incomplete-amount span merge: when the BIO head cuts one amount in
two at a word boundary with no connector at all, the two spans are
merged back into one. The first span must be an incomplete amount --
its last meaningful token is a bare number (
CARD_*/PFX_*/DIGITS) with no unit after it -- the second must start with aUNIT_*(the unit the first is missing) or anotherCARD_*, and exactly one whitespace character may stand between them, so nothing (not even punctuation) is crossed. Repeated until stable, so a chain collapses in one go:"ચોંસઠ લાખમાં"(64 + lakh) → 6400000,"पाव कोटीचा"→ 2500000,"दस बीस हज़ार"→ one span, which R8 below then reads as a range. Two complete amounts are untouched ("5 lakh 3 crore": the first span already ends in a unit). - R18 — boundary artifacts: two real-text shapes the BIO head glues into
one span although the writer wrote two things. A trailing 4-digit
year (1900–2099) after an already-closed amount is trimmed off —
"इनाम दस हज़ार 1987 में बनी"is a film title plus its release year, and decodes as 10,000 rather than 11,987. A leadingDIGITStoken whose value equals theCARD_*right after it is a restatement and is dropped —"agni 5 panch hajar kilometer"says five thousand twice, and decodes as 5,000 rather than 5,005. The incomplete-amount merge above refuses the same two shapes, so a span split at that boundary is not re-joined. - Bound forms and fused number words: a letters run written solid can
be split into two or three unanimous sub-runs in
PFX? CARD? UNIT?order (at least two present) and each part kept on its own class instead of being smoothed into its neighbour —"दोनशे"(2 + 100),"पावणेचार"(0.75 + 4), and the three-part"સાડાત્રણસો"(PFX_SAADHE+CARD_3+UNIT_SAU= 350). Each sub-run normally needs ≥ 2 characters of its own evidence; a single-character sub-run is kept only when its surface is a bound number form the language pack declares for exactly the surface that follows it (bound_formsinsrc/data/lexicon.json) — Gujarati"બ"isCARD_2only in"બસો", so"બસો"= 200 while a stray 1-char sub-run anywhere else is still smoothed away. The same map is what makesverifyTokensaccept[("CARD_2", "બ"), ("UNIT_SAU", "સો")]and reject a standalone"બ". - Lexicon-"O" gate: a span is dropped when any of its meaningful
tokens' surface is declared an ordinary word (lexicon class
"O") by a language pack and carries no other class anywhere in the lexicon union — the indefinite plurals"karodon"/"करोडो"("crores of", no definite amount), which the model likes to tagUNIT_CRORE. A surface that some other pack also declares a real number word is a cross-pack conflict and is left alone. - Possessive trim: a trailing
's/’s(1-2 letters) is stripped from the end of a word and excluded from the span ("2 lakh's"→"2 lakh"). - Confidence filter: a span's RAW confidence is the mean of the max BIO
softmax probability per character; spans below 0.5 are dropped. This gate
is on the raw score and is unaffected by calibration — the reported
confidenceis that same raw score mapped throughsrc/data/calibration.json(see "Calibrated confidence"), andParseOptions.minConfidencethresholds the calibrated value afterwards. - Only after all of the above does the deterministic arithmetic core run
on the resulting token sequence, which also treats a
RANGEconnector between two amounts that BOTH already carry a unit and are strictly descending (e.g."ek lakh dus hazaar"mistaggedRANGEon the space) as one additive amount rather than a[low, high]range — genuine ranges (only one side has a unit, or both do but ascending) are unaffected. - R7 — juxtaposed ranges with no connector word: two adjacent
coefficient+unit terms with NO
RANGEtoken between them at all (e.g."teen hazaar paanch hazaar","bees lac pachees lac","तीन हज़ार पाँच हज़ार") are evaluated as a[low, high]range rather than summed, when both terms carry an explicit coefficient and the second term's unit is the same size or larger — this is the same shape of arithmetic as an explicit range connector, just spoken without one. - R10 — leading-zero digits are never a coefficient: a span whose first
digits token starts with
0and has two or more digits ("GJ05 CD","007") is dropped; vehicle plates, PINs, dates and phone fragments, not amounts. A fractional part after a dot ("1.05 lakh") is exempt. - R8 — juxtaposed spelled cardinals: two adjacent spelled cardinals
with nothing but a space between them and the smaller first (
"do teen lakh","paach ten hazar","तीस पैंतीस हज़ार") are a[low, high]range, evaluated once with each cardinal; digits are excluded and R7 takes precedence when it applies. Multiplicative stacking ("das hazaar crore", second term has no coefficient), descending additive chains ("ek lakh dus hazaar"), and cardinal-only juxtaposition ("do teen lakh", already repaired into an explicitRANGEupstream) are all unaffected; two equal terms ("paanch lakh paanch lakh") fall back to plain additive doubling rather than a degenerate[x, x]range.
An optional linear-chain CRF (--crf, see python/README.md) can replace
the plain per-character argmax with Viterbi decoding, but experiments on
this dataset found it doesn't beat the plain model — 0.957 vs. 0.965
combined gold value accuracy at best — while training ~7x slower and
occasionally showing unexplained late-training loss blowups; the
structural rules above already give the decoded label sequence most of
the coherence a CRF would add. It ships as a tested, opt-in flag; the
bundled weights do not use it.
One more detail that matters more than it looks like it should: the
runtime right-pads the character-id array with 24 pad tokens before
running the forward pass (mirroring python/sankhya/np_infer.py's
pad_ids/PAD_TAIL=24), because training always right-pads every
example to the model's max length the same way. Running a short, tightly
cropped input (e.g. the bare 4 characters of "2.5L") without that
padding measurably corrupts predictions — the model was never trained on
inputs that end at the literal edge of the array. The padded tail's
outputs are discarded; only the real characters' predictions are used.
-
Languages. One language pack per language/script, all sharing the same (language-independent) arithmetic core, class inventory and decoder. Currency and unit detection scan every pack's marker lists together, so mixed-script input in one string is fine.
pack language / script status hi_latnRomanised Hindi / Hinglish ( sava lakh)shipped, in the bundled weights hi_devaDevanagari Hindi ( डेढ़ लाख,सवा करोड़)shipped, in the bundled weights mr_devaDevanagari Marathi ( दीड लाख,साडेतीनशे)shipped, in the bundled weights gu_gujrGujarati ( દોઢ લાખ,બસો)shipped, in the bundled weights Bengali and Tamil/Telugu/Kannada are planned the same way — see Roadmap.
-
Offsets are into the normalized string.
parse()'sstart/endindexnormalizeText(text), not the raw input, in the rare case NFC normalization changes the string's length (seenormalizeTextin the API section above); in JS, an astral (surrogate-pair) character such as an emoji also counts as two UTF-16 units in those offsets, same as any other JS string indexing. -
Text longer than 128 characters is processed with a sliding window (128-char windows, 16-char overlap) and results are merged/deduplicated by span; extremely long inputs may still miss a span that straddles a window boundary in an unlucky way.
-
Bare single cardinal words without a unit are not reliably extracted.
"unnasi"alone produces no span, while"unnasi hazaar"correctly resolves to 79000 — a bare number word needs a unit, prefix, or digit context to be tagged. -
Long multi-term ranges still split incorrectly. Constructs like
"paanch se sadhe saat lakh"(a range where one side itself carries a prefix word) are not reliably decoded as a single range. -
Fullwidth and Arabic-Indic digits are not normalised. Only Devanagari digits (U+0966-U+096F) are mapped by
normalizeText; fullwidth (2) and Arabic-Indic (٢) digit forms are left as unknown characters, so a phrase like"2 lakh"loses the digit and falls back to the unit's default value. -
Model quality: see Accuracy above. The known miss categories there (wrong span boundaries on multi-span/range phrases, unusual typos, long multi-term ranges, occasional spurious spans) are model-quality issues, not bugs in the arithmetic core, which is unit-tested directly and independently of the model in
test/core.test.ts.
src/— the JS/TS runtime: char encoding, CPU forward pass (infer-cpu.ts), WebGPU forward pass, decode, the arithmetic core (core.ts), the public API (index.ts), the confidence calibration map (calibration.ts+src/data/calibration.json), and the bundled default weights (src/data/default-weights.json).test/— Node test files (node --test), including parity fixtures generated from the Python reference implementation (test/fixtures/parity.jsonl,decoded.jsonl— seepython/README.md's "Ship to the npm package" section).bench/—parse()/parseBatch()latency benchmarks.demo/— an interactive demo: live parse as you type, a per-character model-internals view (inspect()), batch benchmark controls (CPU vs WebGPU), a raw-JSON view of results, a theme toggle, and shareable state via URL hash.python/sankhya/—classes.py(shared class vocabulary),core.py(the deterministic arithmetic core),langs/(language packs, e.g.hi_latn.py),noise_latn.py(typo/spelling-variance injection),generator.py(synthetic labelled-data generator),model.py(the char CNN),train.py,export.py,np_infer.py(numpy reference forward pass, what the JS port mirrors),decode.py,eval_gold.py,make_fixtures.py.python/tests/—test_core.py,test_decode.py,test_generator.py, andgold.jsonl(the hand-written gold set).docs/DATA_GRAMMAR.md— the data/grammar spec the generator and language packs implement.
Everything here is scoped to Indian languages — there's no plan to support non-Indian numbering/currency shorthand.
Devanagari Hindi pack (डेढ़ लाख).Done. Thehi_devalanguage pack, Devanagari currency markers, Unicode-aware (\p{L}/\p{M}) letters-run repair for matras/nukta/virama, and input normalization (NFC + Devanagari-digit mapping) are all in place, and mixed Latin/Devanagari input is supported. Seepython/README.mdfor the training-side status and gold-set numbers.Other Indian languages as packs.Marathi (mr_deva) Done. The pack (cardinals 1-99 with phone-typed variants, fused hundredsदोनशे, prefixes सव्वा/दीड/अडीच/साडे/पावणे/अर्धा/पाव, case endings), a verified 591-line LLM corpus and a 173-example gold set (python/tests/gold_mr.jsonl) are in the shipped weights. Gujarati (gu_gujr) is Done. The pack (cardinals 1-99 with phone-typed variants, the glued hundreds બારસો/ત્રણસો and the two irregular ones બસો/છસ્સો, prefixes સવા/દોઢ/અઢી/સાડા/પોણા/અડધો/પા, case endings), a verified 583-line LLM corpus and a 162-example gold set (python/tests/gold_gu.jsonl) are in the shipped weights. Bengali (দেড়, আড়াই) and Tamil/Telugu/Kannada number words are next. Same shape each time — a new pack, currency markers, a charset rebuild and a retrain; see the "adding a language" checklist inpython/README.md.- A WASM SIMD kernel, if sub-millisecond latency is ever needed beyond what the plain-JS CPU path already gives.
npm install
npm run build # esbuild -> dist/index.js (ESM), tsc -> dist/*.d.ts
npm test # node --test over test/*.test.ts
npm run bench # parse() and parseBatch() latency
npm run size # gzipped dist/index.js sizeSee demo/index.html for the full interactive demo (live parse,
per-character model view, benchmark controls, JSON view, theme toggle,
shareable hash) — it imports dist/index.js directly (no build step
needed beyond npm run build). Live at
https://athrvk.github.io/gpu-sankhya/.
The normal flow: bump the version and push to master.
npm version patch|minor|major # bumps package.json and commits + tags locally
git push --follow-tags origin masterYou can also just edit the version field in package.json in a PR — no
need to run npm version or create a tag yourself. Either way, once the
new version lands on master, the publish workflow detects that
package.json's version isn't on npm yet, builds, tests, publishes (with
provenance), and creates the matching vX.Y.Z git tag if it does not
exist yet. Running the workflow manually via workflow_dispatch does the
same check and publishes only if the version is not on npm.
Trusted publishing (OIDC, no NPM_TOKEN) must be configured once on
npmjs.com for this to work: package page -> Settings -> Trusted publisher,
with Organization/user athrvk, Repository gpu-sankhya, Workflow
filename publish.yml. The first release was published manually with
npm publish.
The .github/workflows/huggingface.yml workflow mirrors the shipped model,
the demo, and the gold evaluation sets to Hugging Face:
- model: https://huggingface.co/athrvk/gpu-sankhya
- demo Space: https://huggingface.co/spaces/athrvk/gpu-sankhya-demo
- dataset: https://huggingface.co/datasets/athrvk/gpu-sankhya-gold
It runs on workflow_dispatch (choose all/model/space/dataset) and
on push to master when package.json, models/default/**,
python/tests/gold*.jsonl, or demo/** change. One-time setup: add an
HF_TOKEN repo secret with a write-scoped Hugging Face token for the
athrvk namespace — without it the workflow prints a notice and skips.
See python/README.md's "Publishing to Hugging Face" section for the
underlying python -m sankhya.hf_push CLI (including --dry-run).