grouping
A Node.js wrapper for the official ATIH grouping engine used in the French PMSI (Programme de Médicalisation des Systèmes d'Information).
This package allows integration of the native C-based groupage module to classify hospital stays into GHM (Groupe Homogène de Malades) and GHS (Groupe Homogène de Séjours) codes, in compliance with the T2A billing system in France.
Overview
This project implements a Node.js native addon using Node-API to wrap the official ATIH (Agence Technique de l'Information sur l'Hospitalisation) grouping engine. The grouping engine is a critical component of the French healthcare system's billing and classification infrastructure.
French Medical Coding Context
The French healthcare system uses the PMSI (Programme de Médicalisation des Systèmes d'Information) for medical activity coding and hospital billing under the T2A (Tarification À l'Activité) system. This project specifically addresses the need for automated grouping of medical stays into standardized categories.
For comprehensive background on grouping algorithms and medical coding in France, refer to this detailed overview.
Technical Architecture
This package consists of:
- Native C/C++ Module: The core ATIH grouping engine in the
atih/folder (see doc/atih/grouping_method.md) - JavaScript Interface: High-level Node.js API in
src/that converts JS objects to ATIH RSS strings and parses the native result - Type Definitions: TypeScript definitions for development support
- Runtime data: ATIH reference tables shipped with the package under
tables/V2024,tables/V2025,tables/V2026
Repository layout
| Path | Purpose |
|---|---|
atih/ | Native C sources for the ATIH grouping engine (grouping/) and the FICUM control module (ficum/) |
src/ | TypeScript API: exports/ (public functions + types), utils/ (RUM model, 022/023 format serializers, enums) |
tables/V{2024,2025,2026}/ | ATIH reference tables loaded at runtime by year |
atih/smr/, tables/SMR/{2025,2026}/, lib/SMR/ | FG SMR: ATIH sources (unmodified), N-API wrapper, binary tables, label files (see FG SMR) |
lib/, txt/ | Supporting ATIH data files |
doc/ | ATIH format specs and the publish runbook |
tools/grouping-fixtures/ | Tooling to capture deterministic test fixtures from the AlphaCode API (see its README) |
src/tests/fixtures/ | Captured .case.json fixtures asserted by the grouping test suite |
The native engine loads its tables relative to the package root (resolved via
require.resolve('@beparallel/grouping/package.json')), not the consuming app's working directory. Thetables/,lib/, andtxt/folders are part of the published tarball (filesinpackage.json).
Public API
The package entry point exports the grouping functions, the error model, and all types:
import {
grpMco, // grpMco(input) picks the engine from the discharge dates; grpMco(input, { edition }) forces one
selectMcoEdition,
MCO_EDITIONS,
McoEngineUnavailableError,
grp, // deprecated: like grpMco(input), but silently falls back to 2026
grp2024,
grp2025,
grp2026,
GroupingError,
BlockingErrorGroup,
RUMEnums // TYPE_ETAB, RUM_ENTRY_MODE, RUM_EXIT_MODE, ...
} from '@beparallel/grouping'
Importing the package loads no native addon: each .node file is loaded by the first call that needs it, and a missing one throws there, with its expected path under build/Release/ and how to get it (pnpm prebuild:install or pnpm build:c:native).
The RUMObject and TYPE_ETAB types used to build input are also available from the generated types path (this is how the AlphaCode valuation service imports them):
import type { RUMObject } from '@beparallel/grouping/dist/types/utils'
Edition selection: grpMco
grpMco(input, options?) groups one stay with one FG MCO edition (MCO_EDITIONS = '2024', '2025', '2026') and returns that engine's result plus engineYear (2024 | 2025 | 2026, the engine that ran; type GrpMcoResult):
grpMco(input, { edition: '2025' })runs exactlygrp2025(input), whatever the discharge dates. An edition outsideMCO_EDITIONS('2027', a number...) throwsMcoEngineUnavailableError.grpMco(input)runsselectMcoEdition(input.rumObjects): the first of 2026, 2025, 2024 that ends any RUMexitDate(DDMMYYYY; RUM withoutexitDateare ignored). A stay with discharge dates in two years goes to the newest one (31122025+02012026→'2026'). When no discharge date matches a shipped edition (none at all, or2023,2027...), it throwsMcoEngineUnavailableErrornaming the dates seen and the shipped editions.selectMcoEdition(rumObjects)is exported so a caller can know (or log) the edition before grouping, without re-implementing the rule.- Input is validated by
GrpMcoInputSchema(zod, strict at the top level:{ rumObjects, typeEtab }, 1 to 99 RUM); aZodErroris thrown otherwise. The RUM fields are checked by the engine (error codes), as before.
Each edition serializes the RUMs to its own format: 023 for 2026, 022 for 2025 and 2024. grp2024 / grp2025 / grp2026 remain available and also return engineYear.
grp(input) is deprecated: it applies the same rule as grpMco(input) but, when no discharge date matches, logs a console warning and silently runs the 2026 engine. It keeps that behaviour for existing callers; use grpMco(input) (or grpMco(input, { edition })) instead.
Input
All grouping functions take a single object validated by Zod:
interface GroupingInput {
rumObjects: RUMObject[] // one or more Résumés d'Unité Médicale
typeEtab: TYPE_ETAB // 1 = PUBLIC_HOSPITAL (ex-DGF), 2 = PRIVATE_HOSPITAL (ex-OQN)
}
A RUMObject mirrors an ATIH RSS line. Dates are DDMMYYYY strings; diagnoses are CIM-10 codes; ZAs are CCAM act rows. A real (captured) example:
const input = {
typeEtab: 1, // PUBLIC_HOSPITAL
rumObjects: [
{
FINESS: '511928447',
RSS: '22676886701954659349',
RUM: '7319798297',
birthDate: '22011959',
sex: '1',
uMed: 'RAC',
entryDate: '19122025',
entryMode: '8',
exitDate: '22122025',
exitMode: '8',
nDA: 2,
nZA: 5,
DP: 'G553', // principal diagnosis
DAs: ['M4806', 'E6694'], // associated diagnoses
ZAs: [
// CCAM acts
{ date: '19122025', code: 'LFFA001', phase: '0', act: '1', mod: 'JK', AN: '1', qty: '1' },
{ date: '19122025', code: 'LFAA002', phase: '0', act: '4', diag: '1', AN: '2', qty: '1' }
]
}
]
}
const result = grpMco(input) // → 2025 engine (2025 discharge), result.engineYear === 2025
The full field set lives in RUMObjectSchema (src/utils/types/rum.types.ts); the enums (entry/exit modes, sex, RAAC, etc.) are in src/utils/types/rum.enums.ts and re-exported as RUMEnums. For more complete examples, browse the captured cases under src/tests/fixtures/.
Output
Each function returns a year-specific result (Grp2024Result / Grp2025Result / Grp2026Result, see src/exports/types/), plus engineYear, shaped like:
interface Grp2026Result {
engineYear: 2026 // engine that ran (2024 | 2025 | 2026)
returnCode: number // raw ATIH return code
returnError: GroupingError | null // mapped error (null when grouping succeeded)
grp_res: {
// classification result
cmd: string // Catégorie Majeure de Diagnostic
ghm: string // Groupe Homogène de Malades
version: string
cret: string
}
ghs_val: { ghs: string /* + supplement/billing counters */ }
rss_inf: { agea: number; dstot: number /* + diag/act lists, signatures */ }
}
Error model
When the engine cannot group a stay it returns a non-zero returnCode, which is mapped to a GroupingError via the dictionary in src/exports/error-management/grouping-errors.dict.ts:
interface GroupingError {
code: number
errorName: string
scope: string | null
blockingErrorGroup: BlockingErrorGroup | null // 90Z00Z, 90Z01Z, 90Z02Z, 90Z03Z
description: string
}
BlockingErrorGroup distinguishes mandatory-control errors from group-1/group-2 blocking errors and FG implementation errors. On success, returnError is null and returnCode is 0.
FG SMR (soins médicaux et de réadaptation)
The package also wraps the ATIH FG SMR (distribution SourcesFGSMR2026_15, which carries the 2025 and 2026 engines). A stay is a suite of weekly RHS; the engine returns a GME (e.g. 0871UC1: GN 0871, readaptation level U, heaviness level C, severity 1) and a GMT (tariff group, e.g. 0858; 9999 = not valued).
import { grpSmr, grpSmr2025, grpSmr2026, RHSObject, TYPE_ETAB, toSmrWeek } from '@beparallel/grouping'
const result = grpSmr({ rhsObjects: weeks /* RHSObject[], one per week, sorted */, typeEtab: TYPE_ETAB.PUBLIC_HOSPITAL })
result.gme // '0871UC1' (HC stays; HTP stays: per RHS in result.rhs[i].htp)
result.gmt // '0858'
result.severity // 1
result.returnCode // 0, or the smallest blocking error code (HTP: smallest non-zero rhs[i].returnCode)
result.returnError // SmrGroupingError | null (label from LIBERR.TXT)
result.rhs // per-week rows: GME, return code, errors, 90-day threshold position, weekly GMT...
- Input:
RHSObject/RHSObjectSchema(src/utils/rhs/rhs.types.ts) is a named-field model of one RHS, serialised to the fixed-width M0D format (m0d-format.ts; each field's M0D position is in its JSDoc). Dates areDDMMYYYY;weekNumberisWWYYYY(ISO week + ISO year, seetoSmrWeek());presenceDaysis 7 ×0/1, Monday to Sunday; CIM-10 codes are written without dot. Input is validated with zod (GrpSmrInputSchema, strict: unknown keys are errors; onesmrStayNumber, weeks sorted, each line within the 38,210-character engine buffer; medical validity comes back as engine error codes). - Engine choice:
grpSmr2025/grpSmr2026force an engine (annee_transmission= that year).grpSmrfollows ATIH's dispatcherfgsmrmain.c: the last RHS decides HC vs HTP; HC goes to FG 2026 when its last week is ≥01/2026, else FG 2025, and is regrouped with FG 2025 when its 90th presence day (or its discharge) is before 01/01/2026; HTP RHS follow their own week (split around week01/2026). It throwsSmrEngineUnavailableErrorwhen FG 2024 (weeks before09/2025) or FG 2027+ would be needed.result.routingsays which rule applied.grpSmr(input, { edition: '2025' | '2026' })forces an edition (same result asgrpSmr2025/grpSmr2026); an edition not inSMR_EDITIONSthrowsSmrEngineUnavailableErrorinstead of falling back to the dispatcher. Edition 2025 starts at ISO week09/2025(Monday 24 February 2025,SMR_FIRST_WEEK_2025), one week before the 2025 campaign's official start (Monday 3 March 2025, week 10). - Valuation: each part of a stay in its own campaign.
grpSmrreproducesfgsmrmain.cfor routing, GMEs, return codes, errors and every 2026 value, but deliberately not for the 2025 parts. ATIH's executable runs FG 2025 in 2026-transmission mode (annee_transmission = 2026): for an HC stay regrouped N-1 it reports the stay GMT9999and, from the 90-day threshold on, the weekly GMT9999with 0 days for the weeks before01/2026; HTP RHS of 2025 weeks get 0 valued days.grpSmrreturns the real FG 2025 values there (FG 2025 run withannee_transmission = 2025), and in an HTP split the week-01/2026row carries the FG 2025 valuation of its 2025 days inpreviousEditionValuation(add it to the row's own 2026 valuation). Price the stay-level values (gmt,hc) with the tariff ofengineYear/version, each weekly GMT with the tariff ofrhs[i].weeklyGmtEngineYear, each HTP row with the tariff ofrhs[i].engineYear(and itspreviousEditionValuationwith that part'sengineYear), and do not bill again in 2026 what was billed in the 2025 campaign.src/tests/grouping-smr-reference.test.tscomparesgrpSmrwith outputs offgsmrmain.cand pins these differences. - Mode:
mode: SmrGroupingMode.VALORISATION(default, « groupage 1 », for billing) orDESCRIPTION(« groupage 2 », GMEd). - Native side: one addon
grpsmr.node(both engines), separate from the MCO addons; tables intables/SMR/<year>/. The ATIH files underatih/smr/SRCV2025|SRCV2026are byte-identical to the distribution; portability is handled inbinding.gyp(see the comments on thegrpsmrtarget) andatih/smr/compat/malloc.h, and.gitattributeskeeps git from normalising their CRLF line endings. Engine state is process-global: do not call the SMR functions from severalworker_threadsat once.
How It's Built
The build pipeline has two stages: native C compilation and TypeScript compilation. Prebuilt binaries are distributed via GitHub Releases so consumers don't need a local C toolchain.
Native C Compilation
node-gyp compiles the ATIH C source files defined in binding.gyp into .node binary addons. Four targets are built:
- grpmco -- FG MCO 2025 + 2026 in one addon (FG distribution with 2025+2026 stays:
fgmco_2026+ SRCV2025/2026, compiled once), exportinggrp_2025_wrapper(used bygrp2025) andgrp_2026_wrapper(used bygrp2026). The two wrappers are unmodified:grp_mco_2025_unit.c/grp_mco_2026_unit.ccompile each one with itsInitrenamed and itsNAPI_MODULEremoved, andgrp_mco_module.cregisters the module. (Before, the same sources were linked twice, intogrp2025.nodeandgrp2026.node.) - grp2024 -- 2024 stays (
fgmco_2025+ SRCV2024/2025) - grpsmr -- FG SMR 2025 + 2026 (
atih/smr/, see FG SMR) - ficum -- FICUM control module (UM authorization file generation/validation)
Build locally with:
pnpm build:c:native
TypeScript Compilation
tsc compiles the src/ directory into CommonJS output in dist/, with type declarations emitted to dist/types/.
pnpm build:ts
Local Development
# Use the pinned Node version (.nvmrc → 24.14.0)
nvm use 24
pnpm install
# Install prebuilt native binaries. `prebuild:install` resolves them in this order:
# 1. If GITHUB_TOKEN is set -> download from the GitHub Release.
# 2. Otherwise -> extract a matching tarball committed under prebuilds/.
# 3. If neither works -> prints guidance to build from source.
pnpm prebuild:install
# Optional: only needed for the remote download path
# cp .env-example .env.dev # set GITHUB_TOKEN
# export GITHUB_TOKEN=... # token with access to the GitHub Releases
# ...or compile the native modules from source (needs python3, make, g++)
pnpm build:c:native
pnpm test
Running Tests
Tests require the native C modules to be compiled first. The pretest script handles this automatically:
pnpm test
This runs pnpm build:c:native (via the pretest hook) then jest. To skip the native rebuild when the C code hasn't changed:
npx jest
To run only the grouping fixture suite:
pnpm test:fixtures
To run a specific test file:
npx jest src/tests/grouping.test.ts
npx jest src/tests/ficum.test.ts
To update snapshots after an intentional behavior change:
npx jest --updateSnapshot
Test fixtures
The grouping suite is driven by captured .case.json fixtures under src/tests/fixtures/**, auto-discovered by src/tests/fixtures/loader.ts and asserted by src/tests/grouping.test.ts.
Fixtures are generated from the AlphaCode compare-to-reference-grouping admin API response using pnpm capture-fixtures. See tools/grouping-fixtures/README.md for the full capture workflow, the Metabase baseline query, and where matches vs. mismatches land.
Prebuilt Binaries
To avoid requiring consumers to compile C code locally, the project uses prebuild and prebuild-install plus a small wrapper that adds a local-tarball fallback:
- Contents: Each tarball includes all native targets from
binding.gyp—build/Release/grp2024.node,grpmco.node,grpsmr.node, andficum.node—under the same paths as a localnode-gypbuild (scripts/install-prebuild.cjschecks for these four). Thebinary.module_namefield inpackage.json(grpmco) is the asset nameprebuild-installresolves for this package; it does not mean only that single file is shipped. - Publishing: On a version tag,
.github/workflows/prebuild.ymlcreates a GitHub Release and runsprebuildon ubuntu-latest and macos-latest (Node ABI aligned with CI), uploading one tarball per OS/arch matrix row. The same workflow also supportsworkflow_dispatch(manual run), in which case it builds the same matrix but uploads to workflow artifacts instead of a release—so maintainers can pull them locally withpnpm prebuild:downloadand commit them underprebuilds/. - Installing: Consumers run
pnpm prebuild:install(seescripts/install-prebuild.cjs). The wrapper resolves the binary in this order:- If
GITHUB_TOKENis set, delegates toprebuild-install -T $GITHUB_TOKEN(download from the GitHub Release host). - Otherwise (or on failure), detects libc via
detect-libcand walks an ordered list of candidate tarballs underprebuilds/<scope>/:- glibc Linux:
linuxglibc-{arch}thenlinux-{arch} - macOS / other:
{platform}-{arch}
- glibc Linux:
- The first existing candidate is extracted into the package root, populating
build/Release/. If none exist, the wrapper exits with guidance pointing topnpm prebuild:create(build a local tarball) orpnpm build:c:native(compile from source). Usepnpm prebuild:install:remoteto bypass the wrapper and run the originalprebuild-install -T $GITHUB_TOKENdirectly.
- If
- Committed tarballs: The matrix produced by a manual
prebuild.ymlrun can be committed underprebuilds/and is shipped via thefilesarray inpackage.json. End consumers on a matchingnode_abi+platform+archtherefore do not need a token to install. The pinned Node ABI for the committed set is the value of.nvmrcat release time (currently 24.14.0). - Postinstall:
postinstallrunspnpm build, which compiles TypeScript only. Downloading prebuilds is not automatic; runprebuild:installafter install, or compile everything from source withpnpm build:c:nativein the package directory.
CI Pipeline
| Workflow | Trigger | Purpose |
|---|---|---|
prebuild.yml | Push tag v* / manual | Tag push: creates a GitHub Release and uploads prebuilts (macos x64/arm64, linux glibc x64/arm64). Manual dispatch: builds the same matrix and uploads workflow artifacts (download with pnpm prebuild:download) |
test.yml | Pull request | Runs tests using the Node version from .nvmrc |
Package Manager
The project uses pnpm, pinned via corepack through the packageManager field in package.json.
Installation
- Install the package
pnpm add @beparallel/grouping
- (Optional) Export a GitHub Token
The package ships committed prebuilt tarballs under prebuilds/ for the supported node_abi + platform + arch combinations, so a token is not required when your runtime matches that set. Only set a token if you need to download a fresh build from the GitHub Release host (e.g. on a Node version or arch that isn't covered):
export GITHUB_TOKEN=your_github_token
or set it in the .env.dev file and run dotenv -f .env.dev run XXX.
- Install the native module
cd node_modules/@beparallel/grouping && pnpm prebuild:install
prebuild:install will try GITHUB_TOKEN first if set, and otherwise extract the committed local tarball. If neither path produces binaries it prints guidance for pnpm prebuild:create or pnpm build:c:native.
In the AlphaCode monorepo this step is wired into the valuation service as the grouping:build script.
Requirements
- Node.js 24.0.0 or higher (the repo pins 24.14.0 in
.nvmrc) - Compatible with macOS and Linux (Windows is not supported)
- For native grouping: either run
prebuild:installafter adding the dependency (downloads compiled.nodefiles), or install a C toolchain (python3,make,g++) and runpnpm build:c:nativeinside the package
📚 Additional Resources
Release process
- Release process: doc/package/publish.md
- Adding a new grouping year (buy/download from ATIH + non-regression fixtures): doc/package/adding-a-new-year.md
ATIH
- Format of the RSS grouped data: doc/atih/122_format.md
- Grouping algorithm: doc/atih/grouping_method.md
- UM authorization file format (FICUM input): doc/atih/um_authorization_file_format.md
The non-grouped 022/023 RSS serialization is implemented in code under src/utils/rum/formats/ rather than a standalone doc.