OpenMed

A third-party Apache-2.0 healthcare-AI package whose 2.0 release adds a structured-data re-identification-risk workflow — quasi-identifier discovery, k-anonymity/l-diversity/t-closeness, generalisation and suppression, and a tamper-evident evidence bundle that refuses to carry an expert’s conclusion; adopt the release-gate pattern, pilot the Turkish text de-identification through the Python API only, and do not put it near slides.

Purpose

This is not one of my repositories. It is an external tool, recorded as a candidate. Nothing in it has been run on Memorial data. There is no local_path because it was read from a scratch clone, not cloned into my code folder.

The verdict is split three ways, and the split is the useful part:

Need Verdict
WSI / slide anonymisation Nothing. No image path exists. Does not read .svs/.ndpi/.mrxs, does not touch TIFF ImageDescription, cannot tell you whether a label directory survives. The shortlist on De-identification is unchanged by one entry.
Free-text pathology reports Pilot only, Python API only. Real Turkish PII assets the stack lacks — but a confirmed over-redaction defect on the default model, no --lang on any text CLI command, and no published Turkish recall.
Verifying that de-identification happened The one genuine contribution. A PHI-safe evidence artifact plus openmed risk gate with a non-zero exit code — the shape of the assertion this wiki’s gap 9 says is missing. Applies to tabular releases only.

Provenance, checked against primary sources on 2026-07-30. The GitHub repository belongs to Maziyar Panahi personally, not to an institution; 2,812 of roughly 2,960 commits are theirs across 59 contributors, so treat this as one person’s project with contributors rather than a team product. The Hugging Face OpenMed org is verified, has 2 users, and lists 2,262 models, 26 datasets and 1,456 followers. The repository description says “2,200+” and its README.md says “2,000+”, while the shipped catalogue models.jsonl has 1,520 rows; the announcement bio says “3,500+”, which matches neither the org count nor the maintainer’s personal account count of 2,816 taken alone. These are marketing figures rather than a claim any decision rests on — recorded so the number on this page traces to the API read, not to a post.

Currency, read from the GitHub API on 2026-07-30: created 2025-10-04, last pushed 2026-07-28, v2.0.0 released 2026-07-28 (two days before this review), 46 PyPI releases, 3,027 commits in the trailing year, 453 open issues, 4,762 stars, 583 forks. This is a fast-moving repository — every finding below is pinned to commit fbe8d11, and a re-read in three months should assume things have changed.

Licence is clean and this was checked properly, because QuPath Hepatocyte Extension taught that licence files are where template defects hide. LICENSE is byte-identical to the canonical Apache-2.0 text (zero diff lines against apache.org/licenses/LICENSE-2.0.txt), pyproject.toml declares license = { text = "Apache-2.0" }, PyPI and the GitHub API both report Apache-2.0, and NOTICE separately quarantines GPL-2.0-or-later sdcMicro as “not bundled, vendored, imported, or required … out-of-process only”. No conflict found among the places a licence is declared. Note this is a statement about those files, not a transitive dependency audit.

Data used

None from Memorial. Everything below was produced on synthetic cohorts generated in a scratch directory, and no data left the machine — the risk modules are stdlib-only and the package ships an offline mode that monkeypatches socket.connect.

Core dependencies are light: pysbd, faker, jieba, pyyaml. The whole structured-risk workflow runs on that base install. The model-backed text de-identification needs transformers (and therefore torch), the Ed25519 signing needs a separate integrity extra, and the DataFrame entry points need pandas/polars — all optional.

Methods

Everything in this section was read from source and then run, at commit fbe8d11, Python 3.12.4, base install only.

The structured-risk workflow, and what it actually does

examples/structured_release_risk.py runs end to end. The pipeline is scan_table (advisory QI discovery) → AnonymityPolicy (explicit role review) → assess_releaseanonymize_release (generalise + suppress) → re-read the materialised bytes → validate_released_outputbuild_release_expert_review_evidence.

It will not let you forget a column. Leaving any source column unclassified is a hard error, naming the columns:

ValueError: Every source column requires an explicit release role; classify columns as a
quasi-identifier, sensitive, direct identifier, non-sensitive, or excluded:
['admission_date', 'length_of_stay', 'zip']

That is schema review enforced in code, and it is the single most transferable idea here. De-identification records that the PembeBobrek key was caught by reading a column name and would have passed a value-pattern sweep. This is that check, made mandatory.

target_k has no default; target_l and target_t do, and theirs are inert. AnonymityPolicy.target_k is a required field (release.py:92) — omit it and you get a TypeError; the CLI’s --k is required=True. The docstring states the intent: OpenMed “does not choose a universal regulatory threshold”. But target_l = 1 and target_t = 1.0 are the permissive extremes of their ranges and can never fail, and the code gates whole branches on target_l > 1 or target_t < 1.0. So a caller who sets only --k silently gets no l-diversity and no t-closeness. The defaults are recorded in the output, never flagged. Two lower-level entry points are less careful and do default target_k=2 silently: enforce_kanon (kanon.py:642) and analyze_k_anonymity/KAnonymityEngine (k_anonymity.py:152, 201, 211).

Discovery refuses to claim completeness. On a 200-row, five-QI table scan_table proposed 30 candidate sets and reported status: advisory-candidates, advisory: true, review_required: true, final_measurement_ready: false, set_size_truncated: true, max_set_size: 4, and a boolean key literally named no_candidate_is_not_evidence_of_safety. It correctly flagged that age + ZIP alone left 194 of 200 rows singleton. The manifest carried no raw cell values.

Cost is in the suppression search, and it explodes as k falls. This is counter-intuitive and worth knowing before pointing it at a real cohort. Same 200-row, five-QI table, suppression_rate=0.10, default node budgets:

target k wall clock suppression subsets evaluated
15 6.1 s 36
10 9.1 s 114
5 22.9 s 1,578
3 281.8 s 30,354
2 825.2 s budget exceeded → ValueError

A weaker privacy target is more expensive because the optimum sits deeper in the suppression search. The k=2 case fails closed with a good error message naming the three ways out — but it took 13.75 minutes to say so, and there is no wall-clock bound anywhere: anonymize_release takes no time-limit argument, openmed/risk/ contains no perf_counter or monotonic, and the evidence schema’s time_limit_seconds is hard-coded None (release_evidence.py:210). Budget by node count only.

The refusal, and where it actually sits

The announcement’s headline is real, and I reproduced every branch of it. The generated evidence bundle carries the review block as pure placeholders, and deserialising a bundle where any field has been filled in raises:

write a risk_conclusion  -> ValueError: qualified-expert review fields must remain placeholders
flip status to approved  -> ValueError: qualified-expert review status must remain pending
name an expert           -> ValueError: qualified-expert review fields must remain placeholders
add a signature          -> ValueError: qualified-expert review fields must remain placeholders
add a review_date        -> ValueError: qualified-expert review fields must remain placeholders
state a methodology      -> ValueError: qualified-expert review fields must remain placeholders

Two corrections to how this is usually described, both of which matter.

It is a deserialisation validator, not a guard on a “conclude” call. The message has one raise site (expert_review.py:1780), one caller (:1450), reachable only through from_dict/from_json. The report dataclass has no qualified_expert_review field at all — the block is a constant emitted by to_dict, so a clean round-trip can never trip it. You have to hand-edit the serialised JSON. It fires with verify=False and with a correctly recomputed integrity hash, so it is a schema validator, not a tamper check.

And OpenMed does ship an API for stating the conclusion — it splits the conclusion rather than refusing it. create_expert_attestation (expert_attestation.py:437) takes conclusion from a closed vocabulary that includes very_small_risk — the HIPAA §164.514(b)(1) finding — and Ed25519-signs it over the evidence digests. docs/reidentification-risk.md:630 calls it with exactly that value. The accurate statement is therefore: OpenMed never derives or endorses the conclusion, and forbids writing it into the generated bundle, but ships the mechanism for a named person to state and sign it. That is a more defensible design than “it refuses”, and it is what the announcement’s own “signs it with their own key” describes.

What the bundle honestly reports, and why “zero rows dropped” is the wrong metric

On my 200-row cohort with target_k=15, zero rows were suppressed — and the release retained no quasi-identifier information whatsoever: every row came out as age='*', sex='*', zip='100**', admission_date='2024', length_of_stay='*', one equivalence class, achieved k = 200. The bundle says so plainly:

row_retention                     1.0  ->  1.0
quasi_identifier_cell_retention   1.0  ->  0.0
mean_qi_distribution_shift        0.0  ->  1.0
information_loss                  0.0  ->  0.78
direct_identifier_cells_remaining 600  ->  0

So the tool is honest and the headline is misleading: row retention and information retention are different numbers, and only the second one answers “was this release worth making”. No raw value leaked into the bundle.

I initially attributed that collapse to target_k=15 and that was wrong — adversarial re-derivation caught it. The driver is target_t=0.0, copied unthinkingly from the shipped example. With library defaults (l=1, t=1.0), target_k=15 on the same cohort gives two classes with sex retained exactly and a 0.80 QI change rate; target_t=0.0 collapses everything at any k, including k=2. Also, zip='100**' and admission_date='2024' are generalised, not suppressed — they look empty only because that fixture had every ZIP sharing prefix 100 and every date inside 2024. The general lesson survives; the causal attribution to k did not.

Text de-identification — one confirmed defect that disqualifies the default path

The relevant question for this group is stage 2 of Report Text Extraction. There are real Turkish assets: a checksum-validated TCKN detector, six Turkish-specific regexes (Ocak…Aralık month names, +90/05xx phones, cadde/sokak/mahalle addresses, postcodes), day-first date handling, Turkish surrogates, and 62 Turkish PII checkpoints. A genuinely offline, model-free rules path exists and found 6 spans on a synthetic Turkish report with no model loaded.

But the default smart-merging stage misclassifies ordinary pathology vocabulary as SWIFT/BIC bank codes. The pattern \b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b (pii_entity_merger.py:413) matches any 8- or 11-character ALL-CAPS token. Confirmed independently, by calling the merger with an empty model prediction:

bic  0.075  'SURGICAL'      bic  0.075  'PATOLOJI'
bic  0.075  'MAKROSKOPIK'   bic  0.075  'INCELEME'
bic  0.250  'FORMALIN'      bic  0.250  'INVASIVE'

FORMALIN and INVASIVE also pass validate_bic() — they are indistinguishable from a real SWIFT code by that validator. This is not filtered out on the default path: the pattern’s safety_sweep_requires_context=True gate applies to the deterministic sweep, but _apply_pii_smart_merging (pii.py:1035-1057) builds entities from all merged spans and never receives confidence_threshold at all. Whether the spans survive turns on allow_semantic_only_matches, which is not model_led — and the default model OpenMed-PII-SuperClinical-Small-44M-v1 is not model-led, nor is the Turkish model. I reproduced the six spans at both lang='en' and lang='tr'. Only privacy-filter-multilingual is model-led and suppresses them.

Pipeline(...).run() does filter them (redacted_text came back unchanged), so the blast radius is path-dependent: the Pipeline API is clean, the merger feeding deidentify() is not. Seeing the final [bic]-substituted string end to end needs transformers, which I did not install — but the entity list the redactor consumes provably contains those spans. [unverified] only in that last rendering step; the cause is confirmed.

Three further bounds on the text path, from the audit rather than my own run, and each cheap to re-check: pii batch — the only folder-level command — has no --lang, no --policy and no --encoding, so one cp1254 file (the normal Turkish LIS export encoding) aborts the whole run with zero files written; there is no Turkish clinical layer at all (no section headers, no negation cues, no morphology, and segment_text(..., language="tr") raises ValueError while pack_coherence_report() still reports coherent: true — a false clean in the self-check machinery); and CANONICAL_SECTION_LABELS has no Macroscopy/Microscopy/Diagnosis/ Immunohistochemistry entries in any language. Zero of the 62 Turkish catalogue rows carry a published F1.

Current state / open questions

Read the docs, they are accurate. Unusually, docs/reidentification-risk.md agreed with the code on every default I checked — required thresholds, non-overwriting outputs, the stated refusal to extrapolate. The README.md, by contrast, does not mention k-anonymity, quasi-identifiers or expert determination once in 31 KB: the entire 2.0 headline feature is invisible from the front door. Its language counts also disagree with themselves (32, 29 and 34 in one file, against 34 in code).

population.py is the strongest module and is worth reading on its own. It ships no reference population and cannot fetch one — the reference is a required positional with no fallback, a whole-repo sweep found no census or ZIP frequency table, and there is no extrapolation machinery. It computes uniqueness exactly and fails closed: an unmatched profile forces exact_risk = 1.0 and achieved_k_map = 0, and meets_k_map is conjoined with reference_model_consistent so an inconsistent reference cannot pass on numbers alone. The phrase “very small risk” appears nowhere in the code, only in human-facing skill prompts.

The verification is weaker than it looks, and this is the finding that matters most here. 485 of 500 risk/compliance tests pass in 7 s at 85 % statement coverage, and the placeholder refusal is genuinely tested. But nothing re-measures k on the bytes being released. validate_released_output copies the earlier claim (policy_revalidated_before_identifier_removal = result.after.meets_policy, release.py:941) rather than recomputing equivalence classes, and Counter/defaultdict/groupby appear zero times in the risk and compliance test directories. An independent oracle run agreed with the declared number on one cohort, so this is a test-coverage gap, not a demonstrated bug — but the guarantee rests on digest-binding a measurement taken elsewhere, not on measuring the artifact. That is precisely the failure shape De-identification already records for Pathology Atlas Pipeline.

Three more holes in the trust chain, all [unverified] by me because the crypto needs the integrity extra, all reported with file:line by the audit and all cheap to confirm: ExpertReviewEvidenceReport.verify() is self-consistency over a public stable_hash, so the mandatory caveats were reportedly stripped, re-stamped and still verified True; nothing binds an Ed25519 key to an identity, which is a free-text string; and expert-attestation-verify exits 0 on a not_approved conclusion, so a shell gate reading $? would pass a rejected dataset. 8 of 8 crypto tests skip on a base install, leaving expert_attestation.py at 53 % coverage.

The shipped agent skill is worse than the tool. skills/reviewing-reidentification-risk opens with a quick-start whose example annotates a record # singleton and then reports k_min 3, singleton_count 0 — because the regex QI hooks match none of that text. The teaching example demonstrates the exact false reassurance the skill exists to prevent, and its correct boundary statement sits in “Edge cases” after the step telling the agent to write a “very small risk” conclusion. If any of this is adopted, do not adopt the skill.

Open questions for a next review: does the [bic] defect also fire under policy="hipaa_safe_harbor" on Turkish ALL-CAPS headers; does the Ed25519 round-trip actually work with openmed[integrity] installed; and what is the actual name recall on Turkish reports, which no amount of reading this repository can answer and which needs a locally annotated held-out set.

Related: De-identification — this is the first candidate that addresses the verification question that page names as the one worth acting on first, rather than adding a seventh anonymiser to a shortlist. De-identification Release Gating — the durable output of this review; the pattern is worth copying even though the tool is not worth adopting. Report Text Extraction — its stage 2 is where the Turkish PII assets would land, and the [bic] defect is why they cannot land there yet. ScanTools — the complementary half: that tool covers slides and this one covers tables, and neither covers the other.

Derived from: repository source read at commit fbe8d11 on 2026-07-30 — openmed/risk/{release,kanon,k_anonymity,population}.py, openmed/compliance/{expert_review,expert_attestation,release_evidence,safe_harbor}.py, openmed/structured/qi_detect.py, openmed/core/{pii,pii_entity_merger}.py, examples/structured_release_risk.py, skills/reviewing-reidentification-risk/SKILL.md, docs/reidentification-risk.md, README.md, LICENSE, NOTICE, pyproject.toml; plus the GitHub, PyPI and Hugging Face APIs read 2026-07-30, and synthetic-cohort runs of anonymize_release, scan_table, the evidence round-trip and _apply_pii_smart_merging.