Repository navigation
Releases
Isocenter 1.0.1
Added
- docs: what a 1.x release may rename or remove, by tier (
docs/api/stability.md, "Renames and removals after 1.0"). 1.0.0 shipped without this section, which was prepared for it in #892 and left out of the cut. The section carries the owner's rulings of 2026-10-01 on #892:- Tier 1. When a 1.x release renames a tier-1 name, the old name stays as an alias of the new one. When a 1.x release removes one, the old name stays and does what it did. Either way, a call to the old name, or a read of it where it is not callable, emits a
DeprecationWarningthat names the replacement or says there is none, in every later 1.x release, and 2.0 removes it. The page lists the deprecated name in tier 1 beside its replacement until then. The position of a parameter, a return shape and a documented behaviour still change only in a 2.0. This qualifies what 1.0.0 published: the page's tier-1 bullet said "A change to them is a 2.0", with no exception. It now says a 1.x release can rename or remove a tier-1 name, or a keyword parameter of one, and keep the old spelling working with aDeprecationWarninguntil 2.0, and that any other change is a 2.0. - Keyword parameters of a tier-1 callable follow the same rule. A renamed or removed keyword is still accepted, does what it did, and emits a
DeprecationWarningthrough 1.x. 2.0 removes it. - Tier 2 changes only in a minor release (1.x.0), never in a patch (1.x.y), with a
BreakingCHANGELOG entry that names both spellings. No alias is promised. This narrows what 1.0.0 published: the tier-2 bullet of the stability page, its "Documented but internal" section, and the[1.0.0]Highlights below say a 1.x release can change a tier-2 name with a CHANGELOG entry, which allowed a patch. The page's tier bullets, its tier-2 section and its exporter-registry subsection now say minor release andBreaking. So do the tier-2 sentences ofdocs/api/entities.md,docs/ocr.md,docs/analytics.md(the manifest's JSON keys,anonymizedand HTML layout),docs/api/persistence.md(the audit-trail methods) anddocs/api/exporters.md. The[1.0.0]Highlights are a released section and are not changed. - Tier 3: no notice.
- The section also says how to see a
DeprecationWarning: by default Python shows one only when it comes from code that runs as__main__. - How the policy and the tests coexist.
tests/test_api_coherence.py's one-spelling pins forbid spellings deleted before 1.0 (scan_for_phi,export_to_parquet,write_project_secret/load_project_secret, the second config loader and others). None of them is a 1.0 name, so the policy does not reach them, and that test is unchanged. A future deprecated alias is a new public name on a tier-1 class, sotests/test_frozen_surface.py::test_every_public_name_on_a_tier_one_class_is_classifiedstays red until the page andTIER_ONE_NAMESclassify it, which is the listing the policy promises. A deprecated keyword that is a real parameter moves the signature tables' pins, which is the intended review point.pytest.inifilters onlyignore:::pydicom.*, so a future IsocenterDeprecationWarningis not made an error.test_the_stability_page_says_what_a_1x_may_rename_or_removepins the section, its place above "Frozen at 1.0", and the minor-release rule wherever the pages state when tier 2 may change.
- Tier 1. When a 1.x release renames a tier-1 name, the old name stays as an alias of the new one. When a 1.x release removes one, the old name stays and does what it did. Either way, a call to the old name, or a read of it where it is not callable, emits a
Changed
setup.pyclassifies Isocenter asDevelopment Status :: 5 - Production/Stable, not4 - Beta. 1.0.0 shipped with4 - Beta, because this change was held in #892 and left out of the cut. PyPI shows a release's classifiers as that release uploaded them, so the 1.0.0 page keeps4 - Beta, and the new classifier shows only from the next upload. pip ignores classifiers, so no install changes. No test ties the classifier to the version: such a test would be red onmainbetween a change of the classifier and the record-back that sets_version.py.- The install instructions no longer offer a release candidate.
README.md,docs/index.md,docs/installation.mdand the home page's hero saidRelease candidates: pip install --pre isocenter. Now that 1.0.0 is final,pip install isocenterinstalls it, and--prewould install any later candidate instead (a 1.1.0rc1, say). The README line becomes "Check which version you have:", the lead-in the two docs pages already use, so the README's two code fences do not sit back to back. The hero keeps the rest of its note. 1.0.0 shipped with the old line: PyPI's 1.0.0 page reads the README as uploaded and keeps it, and the live1.0/site keeps it until the site is next deployed..github/SECURITY.mdsaid fixes go to "the latest 1.0 release candidate until 1.0.0 is final, then the latest 1.0.x release", and now says the latest 1.0.x release. Left as they are, because none is an install instruction: the1.0.0rc3placeholders in the issue templates,docs/midi-b.md'sisocenter[textdetect]==1.0.0rc22(it reproduces a measurement of that version), the notebooks' installs from git, and the historical1.0.0rcNmentions inRELEASING.md, code comments and tests.
Isocenter 1.0.0
Highlights
1.0.0 is the first stable release of Isocenter. These highlights are for a reader who upgrades from 0.9.x. They give the main changes of 1.0.0rc1 to 1.0.0rc22 and of this release. Each item names its issue. The full entry is in CHANGELOG.md, in the section of the candidate that made the change. The entries of this release follow the highlights.
What 1.0 promises
The API has three tiers. The API stability page names each one (#26, #787).
- Frozen (tier 1). These names keep their spelling and behaviour for every 1.x release. They are
Session, its methods and their parameters, the shapes they return, and the other callables in the frozen tables. A change to them is a 2.0. - Documented but internal (tier 2). These names are safe to call. A 1.x release can change them, with a changelog entry that names the old spelling and the new one.
- Private (tier 3). Names with a leading underscore, and every module that the page does not list, can change without notice.
- The output. For the same input, configuration and project secret, a 1.x release exports what the previous release exported, or its changelog says what changed. Each release's export of a fixed cohort is fingerprinted, and a release stops on a difference that no changelog entry names (#717).
- The configuration. Every 1.x release loads a file that 1.0 loads. A 1.x release that applies an unchanged file differently raises the schema's minor version (#762, #782). A privacy profile name is pinned to its PS3.15 edition:
basicmeansbasic@2026cin every 1.x release (#714). - Plugins. The exporter registry is provisional until 1.1. In 1.0, an export through a third-party exporter grades
REVIEW_REQUIRED(#527).
Before you upgrade from 0.9.x
Isocenter migrates nothing from 0.9.x (owner ruling on #790).
- Identity tokens. Releases before 1.0 wrote the identity token into the wrong element of the Encrypted Attributes item. 1.x refuses a token in that layout by name and does not decrypt it. To recover such an identity, use Isocenter 0.9.x and the key that locked it (#790).
- Stores. A 0.9.x store opens in 1.0. On open, 1.0 adds the columns it records into, empty, and converts no value that the store holds.
- Its PHI statuses have no recorded policy. An export of them grades
REVIEW_REQUIREDuntil you runaudit()andanonymize()under your configuration, thensave()(#555). - Its instances keep their source UIDs until the next
audit()andanonymize()replace them (#544). Files exported before 1.0 keep their source UIDs, and 1.0 exports of the same data do not link to them by UID. - 0.9.x grouped files with no Patient ID under one patient,
''orUnknownPatient. Such a store keeps that grouping, because a split would give dates already shifted a second offset. While a patient's ID in the store is still''orUnknownPatient, every open writes oneWARNINGrow that gives only a count. Every report over the store then gradesREVIEW_REQUIRED.audit(),anonymize()andsave()do not remove a row already written. A group thatanonymize()gave a pseudonym, in 0.9.x or in 1.0, is not counted. So a store that was anonymized before 1.0 can hold such a group with no row. To separate the subjects, ingest their source files into a new store (#584). - 0.9.x does not write the new columns. A status that 0.9.x saves into a store that 1.0 opened then sits beside the policy of 1.0's last scan, and 1.0 cannot detect this. Before you open a 0.9.x store in 1.0, keep a copy of it. Do not save into the 1.0 store from 0.9.x. If you go back to 0.9.x, use the copy.
- Its PHI statuses have no recorded policy. An export of them grades
- Configuration files. A file that 0.9.x's
create_config()or auto-save wrote loads unchanged, with one exception. Before 0.9.8,set_phi_tag(replacement=)saved aphi_tagsrule with areplacementkey. The loader refuses that key. Rename it tovalue(#538).- The loader refuses an unknown key, a value of the wrong type, and a
versionthat this library does not read (#711, #712, #713). - 0.9.x cannot load
privacy_profile: basic@2026c, which 1.0 writes. Writebasicin a file that both must load (#714). - A change to the configuration no longer writes the file. Call
configuration.save(), or setconfiguration.auto_save(#715).
- The loader refuses an unknown key, a value of the wrong type, and a
- Renamed and removed names.
start_patient(id=)is nowpatient_id=.add_rule(model=, zones=)is nowmodel_name=andredaction_zones=.set_phi_tag(replacement=)is nowvalue=. The old keywords raiseTypeError(#26).write_project_secret()andload_project_secret()are removed. A project secret stays in the store that made it, and a copy of the store is the same project (#716).ConfigLoader.load_redaction_rules(),ConfigLoader.load_phi_config()and theconfig_pathparameter ofPhiInspectorare removed (#729).export_dataframe()requires its output path (#812).
- Selections. A bare
straspatient_idsraisesTypeError. Writepatient_ids=["X"](#696). An ID or UID that names nothing is counted, and an export with one gradesREVIEW_REQUIRED(#686, #725). - A stricter grade. A run that graded
PASSin 0.9.x can gradeREVIEW_REQUIREDin 1.0. The analytics page lists every condition. These are the main new ones: - Changed output under
basic@2026cand the floor.- Each UID that Table E.1-1 codes
Uis replaced by a UID derived from the value and the store's project secret. One UID gets one replacement everywhere in the store, so references between exported files still resolve (#544). - An element that Table E.1-1 codes
Dgets a dummy value of its VR (#557). - Retired curve groups, whole overlay groups, Content Sequence, Graphic Annotation Sequence and Referenced File ID are removed (#556, #840, #848, #1133).
- Every exported DICOM file that is not JPEG 2000 is Explicit VR Little Endian, so each element states its VR (#1107).
- Each UID that Table E.1-1 codes
- Other changed behaviour.
- At ingest, files with no Patient ID are no longer merged into one patient. Each study that they belong to is its own patient. A store that 0.9.x wrote keeps its grouping (see Stores, above) (#584).
- A machine rule with
serial_number: "*"now also covers series with no Device Serial Number (#1108). - A private-creator key matches its creator in any case and spacing. Two keys for one creator with different rules are refused (#1147).
New in 1.0
- De-identification markers. An exported DICOM file that the audit found clean, or that
anonymize()de-identified, says how (#554, #978, #1011).- Patient Identity Removed
(0012,0062)isYES, unless the file says Burned In Annotation(0028,0301)YES. - De-identification Method
(0012,0063)names the version and the policy. - Longitudinal Temporal Information Modified
(0028,0303)isMODIFIEDwhen this store shifted a date in the file. It isREMOVEDwhen every date in the file is absent, empty or its VR's dummy. Otherwise the file keeps the source's value. But a source'sUNMODIFIEDis not kept when this store took a top-level date out of the file. - A rule of any action on one of these three tags stops that stamp.
- Patient Identity Removed
- Burned-in text. A
burned_in_textpolicy makesredact()read the images, then redact, report or withhold the text that it finds (#1108). The policy has three more settings (#1121):scope: which images to read.target: which kinds of text to act on (names, dates, IDs).detector: how to find text. Use tesseract, or a text-detection model that ships with Isocenter and runs under OpenCV (thetextdetectextra).
SCRUB. ASCRUBrule removes the identifiers inside a text element and keeps the rest of the text (#1130, #1146). It removes the kinds that you name: names, dates, IDs, organizations, phone numbers, addresses, sites and initials. WithSCRUB, you can keep a structured report's content and scrub its text values (#1131).- More precise rules. A private-element rule can name its creator, as in
'0009,"GEMS_IDEN_01",27'(#1132). A machine rule can select the series with no Device Serial Number (serial_number: ""), or select by device (match:) (#1108). - Waveforms. A WFDB export names coded ECG leads
I,II,aVR,V1and so on (#828, #832). - Documentation. The site has one folder for each minor version (#866), and it is built with Sphinx (#1155). Its tutorials run as part of the test suite (#27). A new page gives Isocenter's scores on TCIA's MIDI-B de-identification benchmark (
docs/midi-b.md).
Security fixes since 0.9.x
- Export path (GHSA-2rc2-r9r5-x7hm). Every release from 0.7.0 to 1.0.0rc15 could write a file outside the export folder when a SOP Instance UID held a path separator. Fixed in 1.0.0rc16. Its entry in
CHANGELOG.mdsays which earlier exports to check. - Free text in structured reports and presentation states. Under
basicand the floor, this text was exported as written. Fixed in 1.0.0rc4 and 1.0.0rc5 (#840, #848). - Two redaction rules on one image. Burned-in text inside a redaction zone could be exported unredacted when two rules covered one image. Fixed in 1.0.0rc10. Its entry in
CHANGELOG.mdsays to runredact()again and export again (#908).
Added
- docs: a MIDI-B benchmark page.
docs/midi-b.md(/1.0/midi-b/) gives Isocenter 1.0.0rc22's scores on TCIA's MIDI-B de-identification benchmark, scored with the benchmark's validation script at commitccc0939: 99.22% of the Validation set's checks and 99.23% of the Test set's, against 72.51% and 68.45% for the default configuration. It sets out the configuration part by part with...
Isocenter 1.0.0rc22
Breaking
- A private-creator key matches its creator in any case and spacing, and two keys for one creator with different rules are refused (#1147). A creator key (#1132) compared the creator exactly, so a file spelling it another way was silently missed: the element kept its value, no row said so, and the grade was PASS. Vendors write one creator more than one way. On the MIDI-B sets with 1.0.0rc21, the UCSF creator is
UCSF BIRP PRIVATE CREATOR 011710XXin 2,126 Validation and 3,516 Test files andUCSF BIRP PRIVATE CREATOR 011710xxin 990 and 1,364. The configuration's rule, written first with one spelling and then the other, left the value of its free-text element in every file of the other spelling each time: 1,101 and then 3,771 failed Validation removal checks, with nothing in the audit or the report. pydicom 3.0.2's own private dictionary holds four creators in two cases each, and the elements the two spellings share have the same definitions:HOLOGIC/Hologic(8 shared),MITRA PRESENTATION 1.0/Mitra Presentation 1.0(6),SPI RELEASE 1/SPI Release 1(8) andMITRA OBJECT DOCUMENT 1.0/Mitra Object Document 1.0(1, with another name).- This corrects 1.0.0rc21's entry for #1132, which said a creator's "case is compared exactly, as PS3.5 and pydicom's
private_blockdo". PS3.5 says no such thing. 7.8.1 makes a creator an LO "identification code", used once in a group, and recommends graphic characters "so that reliable recognition is more likely"; it has no rule for comparing two creators. The exact==is pydicom'sprivate_block, a toolkit's choice. - Now the key's creator and the file's are each reduced to one identity before they are compared, for every action (owner ruling Q1 (a)): surrounding spaces stripped, each run of inner spaces made one, and ASCII
a-zupper-cased (config_manager._creator_identity; no other letter is touched). So'0009,"GEMS_IDEN_01",27'matchesgems_iden_01, andACME IMAGING 01with two spaces isACME IMAGING 01. A creatorKEEPmatches a case variant too, and still beats a concrete-tag key on that element. - The key and the row keep their spellings. A key keeps its creator as written, because the policy fingerprint hashes keys as held: folding the key would move every creator-keyed store's
v1:hash and(0012,0063). The audit row names the creator as the file spells it:(creator Hologic)under a key namingHOLOGIC. - Breaking: two keys that name one creator at one group and offset, in different case or spacing, with rules that act differently, are refused (owner ruling Q2 (a)).
load_config()raisesValueError: <path>: phi_tags keys '0009,"ACME IMAGING 01",27' and '0009,"Acme Imaging 01",27' name the same private element with rules that act differently: a creator key matches its creator whatever its case and runs of spaces (PS3.5 7.8.1 gives no rule for comparing two creators); give both keys one rule, or keep one key, and so doaudit()over a policy assigned in code andPhiInspector(config_tags=). Such a file loaded in rc21, where the two keys were two rules for two spellings. Now they name one element, and letting one of aKEEPand aREMOVEwin silently would be #1147's shape again. Rules that act the same load: they may differ inname, in the case ofaction, invalue: nullagainst novalue(REPLACEwrites the VR's dummy under either, though the policy fingerprint still tells them apart), and in the order of aSCRUBtarget, compared as a set, since the order decides only which kind a span two kinds find is counted for and the first key's rule is the one applied. The first key in the policy names the finding; the MIDI-B configuration's two UCSF keys are that case.set_phi_tag()on one of two such keys sets its rule on both, so the policy stays loadable and the element takes the new rule whichever key the scan meets first; it refuses nothing new. Two spellings of one key that differ only in hex case are still one key, with the collision WARNING, as before. - Not matched, as before: a creator that differs in anything else, such as
GEMS_IDEN_1againstGEMS_IDEN_01, a space taken out, or a non-ASCII letter's case. Not changed: theremove_private_tags: truesweep, and the #765 private-UID-copy arm, which leaves alone an element a creator key names, now in any spelling. - Run
audit()andanonymize()again on a store audited under 1.0.0rc21 with a creator key. The policy fingerprint does not move, so a file of the other spelling that was scanned clean under rc21 still reads as current, exports its value and draws no #555 notice until the store is audited again. CONFIG_VERSIONstays 2.0. No key or shape is added; this changes how the unshipped 2.0 row applies, before the final tag. The behaviour digest's opt-in SR gained a block namingB3 DIGESTasb3 digest, whose element is now removed; both"2.0"rows moved for that alone (the digest held with the fold before the fixture was widened).- Output: one difference, the input of
synthetic:private_creators, which gained a third file,private_creators-3.dcm, whose creator is speltgems_iden_01(owner ruling Q8 (b)); configuration C's creator key removes its date, and A sweeps it and B keeps it as for the other two. No other cell moved:checkagainst the rc21 recording at the two code commits exited 0 on 3.12, so neither #1147 nor #1146 changes any existing member's output.
- This corrects 1.0.0rc21's entry for #1132, which said a creator's "case is compared exactly, as PS3.5 and pydicom's
Added
SCRUBreads a name cut off at the element's maximum length, and has four more kinds:phones,addresses,sitesandinitials(#1146). Measured on the MIDI-B sets with 1.0.0rc21 andtarget: [names, dates, ids]on 15 free-text elements, removal checks failed after a scrub 15,696 times on Validation and 24,385 on Test, because the identifier left had no kind to find it: a site acronym after "at" (10,231 and 20,539), staff initials after "by" (4,382 and 1,025), and facility names, street addresses and phone fragments (about 1,080 and 2,820).orgsfound none of the acronyms.- A name cut at the VR's length.
ep2d_diff_b50_400_800_tra_p3_TRACEW_DFC_MIX for Christopher Poweis a Series Description cut at LO's 64 characters for a patientPOWERS^CHRISTOPHER;namesmatches whole words, so it leftPowe. Now, in a value exactly at its VR's maximum (LO 64, SH 16, ST 1024, LT 10240 characters, PS3.5 Table 6.2-1; UC and UT have none), the last word, 3 or more letters after a space or at the start, is a name when it is the beginning of one of the instance's names. A protocol segment (..._tra) is not a word. It is read in the first round only, and a private element's limit is its recorded VR's. What it wrongly takes: a value exactly at its limit whose last word begins one of the instance's names (... SAGfor a patientSAGAR^...). Not seen: a value cut by bytes under a multi-byte character set, which is shorter in characters. phones(owner ruling Q3 (a)): a North American number with-or.between its groups or a bracketed area code, an optional leading1/+1and an optional extension, and an international number with its+(8 to 15 digits, spaces allowed after the+). It takes the issue's(606)066-5106x76, whichidscut to(606). No spaces between a North American number's groups, soMatrix 512 256 1024is kept; it does take a number triple written like a phone number (Slices 120-240-3600,Recon 512.512.0001,kernel (512) 256-1024: 7 of 9 invented traps).addresses(Q4 (a)): the full form only, a street number, one to three title-case words, a case-sensitive street suffix, then city, two-letter state and ZIP (157 Chisholm Trl Thibodaux, LA 70301). It misses a street line alone, which4 Phase Abdomen Ctwould also be (0 of 8 traps for the full form, 7 of 8 for the street line).sites(Q5 (a)): 4 to 8 capitals as a whole word after a lower-caseat, removed with theat(PET-CT STUDY at CHAJHbecomesPET-CT STUDY). A word runs on through digits and_, sot2_tse_tra_320_p2 at HASCC_2is kept whole rather than cut to_2. 0 of 16 anatomy and modality traps (Nodule at RUL,Imaging at MRI,PERFUSION AT REST, whoseATis upper-case); it misses 3-letter sites (at FGH). What it wrongly takes: a 4-to-8-letter clinical word in capitals after a lower-caseat:Perfusion at RESTbecomesPerfusion.initials(Q6 (a)): a lower-casebyand 1 to 3 capitals as a whole word (by SS_2is not initials), only directly after a span another kind removes from the same value:Ordered for 2675888487 by SSbecomesOrdered(rc21:Ordered by SS). A free-standingOrdered by SSis kept, and so isConfirmed by CT(0 of 12 traps).- Each is opt-in, named in
targetlike the others, and needs no model. Rows count each kind by its noun (removed 1 phone number, 1 address, 1 site, 1 set of initials) and quote no text. None is a burned-in text kind (owner ruling Q-B12 stands for pixels). On the public cohort's 733 distinct text values and the 54 strings oftests/support/scrub_corpus.py, none of the four took anything.
- A name cut at the VR's length.
Changed
- docs: the documentation site is built with Sphinx and pydata-sphinx-theme, not MkDocs/Material/mike. The site is the same set of pages in the same versioned folders; what builds and deploys it changed.
- The URLs are unchanged. Pages are still served at slash URLs (
dirhtml), and every heading keeps the id the MkDocs site gave it:docs/_ext/slugs.pygives each heading Python-Markdown's id, measured as 276 of 276 headings and 0 of 179 in-site fragment links broken. The deploy fails if that hook stops running, by checking for one id only it makes. - New landing pages at
get-started/,tutorials/,guides/,api/andabout/, so each section of the navigation has a page of its own; no existing page moved. - **The API reference is rendered by...
- The URLs are unchanged. Pages are still served at slash URLs (
Isocenter 1.0.0rc21
Changed
basic@2026cand the floor remove Referenced File ID(0004,1500), an element Table E.1-1 has no row for (#1133). It is a Directory Record element of the Basic Directory IOD (the DICOMDIR, PS3.3 Annex F), not part of any composite instance, and its value is a file-set path. Sites build those from MRNs, accession numbers or dates. With no row there was no rule, so an image file carrying one exported it as written underbasic, the floor and the scaffoldcreate_config()writes, with(0012,0062) YESand a PASS grade. Found on the MIDI-B Test set with 1.0.0rc20: 64 MR instances of one series carried a two-component path. These were the only text-removal failures left in either set. The flag comes from TCIA's own review, not from HIPAA or PS3.15, but the reason is general: a directory path is free-form site data.-
Now. The rule is
REMOVE. It reaches the element wherever it sits, nested items included, and each removal writes the usualREMEDIATION_REMOVErow.tests/support/annex_e.pynames it inADDED_RULES, a table of rules on single elements the table has no row for, beside the overlay group rule (#556). Configuration lists it among the departures. -
Only this element. By the owner's ruling, the rule is on
(0004,1500)alone, not on group 0004. Referenced SOP Instance UID in File(0004,1511)keeps itsUrow, and the other Directory Structuring elements still have no rule. -
Keeping it is the configuration's call.
"0004,1500": {action: KEEP}keeps it, andprivacy_profile: nonehas no rule for it. -
basic@2026chas 649 rules where it had 648, and 415 of them areREMOVEwhere 414 were. Its digest and the floor's move in bothPINNED_DIGESTSandFROZEN_AT_1_0(tests/test_profile_editions.py), which the profile pin allows before the v1.0.0 tag with this entry. -
A store audited under 1.0.0rc20 or earlier. The profile's rules are in the policy fingerprint:
- the floor's short hash moves from
v1:729f4bbftov1:b2d62bec; basic@2026c's moves fromv1:d04ffd6etov1:80408ecf.
Exported with no new
audit(), such a store draws the #555 notice: a WARNING row naming both policies, no de-identification markers, and a report that gradesREVIEW_REQUIRED. Runaudit()andanonymize()again before exporting it. A WARNING row an earlier export wrote stays in the store's audit log, so that store's report still gradesREVIEW_REQUIRED. - the floor's short hash moves from
-
CONFIG_VERSIONstays 2.0. This changes a pinned profile's own rules before the v1.0.0 tag freezes them, as #840 and #848 did, and those rules already move the fingerprint. No key is added. The behaviour digest (tests/test_config_behaviour_is_versioned.py) does not move: none of its inputs carries the element. -
Output:
fingerprint/output.jsonwas retaken on 3.12 and checked clean on 3.14t.compareagainstmain's file reports 1165 differences, all0012,0063LO in the five DICOM arms (A.dicom,A.dicom-j2k,A.reopened.dicom,B.dicom,B.dicom-j2k): the De-identification Method's short hash.- Configuration A, whose policy is the floor's:
v1:729f4bbfbecomesv1:b2d62bec(588 files). - Configuration B, which keeps private tags:
v1:9d04b00abecomesv1:27d1c730(392 files). - The other 185 files hold values longer than the text limit, because the source carried a De-identification Method of its own, so they are recorded as a hash. They match the same two moves.
No cohort member's export carries
(0004,1500). Only pydicom's seven DICOMDIRs hold it, and each fails ingest for want of a SOP Instance UID. So no other element, path, outcome, accounting row or WFDB file changes. - Configuration A, whose policy is the floor's:
-
- A colour image with no Planar Configuration
(0028,0006)is read color-by-pixel, under every transfer syntax but RLE Lossless; it was refused at ingest (#1134, owner rulings of 2026-10-16). Planar Configuration is Type 1C, required when Samples per Pixel is above 1, and pydicom validates the header before any decoder runs. So such a file was one ofIngestSummary.failures, with theERRORrowIngest failed for <path>: Decompression Failed: AttributeError: Missing required element: (0028,0006) 'Planar Configuration', and was missing from the export. Found on the MIDI-B Validation set with 1.0.0rc20: 14 GE ultrasound images (Explicit VR Little Endian, RGB) were refused this way, and each decodes with Planar Configuration 0.- Now, where the element is absent or holds no value, ingest reads the samples color-by-pixel (0) and records 0 on the instance, so a reopened store,
redact()and the export read 0, and the export writes 0. A colour icon of that shape is carried the same way; it was dropped with aDATA_LOSSrow. Each export of such an image logs one INFO note,Planar Configuration (0028,0006) was absent in the source file, where an image of more than one sample per pixel requires it (PS3.3 C.7.6.3.1.3); read as 0, color-by-pixel, at ingest, and written as 0. Nothing in the file confirmed that layout.("held no value" for an empty element; prefixedAt 0088,0200[0]:and so on for an icon), in the session's log, as the export's other descriptor notes are. No audit row is written and the grade does not move. Section 5 of the compliance report counts them, when there are any:* **Planar Configuration:** N image(s) read with an inferred Planar Configuration (0028,0006), as 0 (color-by-pixel), because the source file stated none.(withand M icon(s)where icons were). The count is taken over the store, so a report made before any export, or after a reopen, says the same;ComplianceReport.planar_configuration_inferredholds the two counts.Instance.get_pixel_data()reads such a source file the same way. The source file is not modified: the element is supplied on the dataset read into memory, before any check of the header, so a refusal of this library's own (#669's 1-bit sentence) still names its cause. - Under compression the codestream fixes the layout. PS3.5 (edition 2026d) says, for JPEG-LS (§8.2.3), "The Value of Planar Configuration (0028,0006) is irrelevant since the manner of encoding components is specified in the JPEG-LS bit stream as component, line or sample interleaved, hence it shall be set to 0", and the same for JPEG 2000 (§8.2.4) and HTJ2K (§8.2.14), "since the manner of encoding components is specified in the JPEG 2000 standard". For JPEG (§8.2.1) it says "JPEG compressed data streams are always color-by-pixel and should be specified as such", and Table 8.2.1-1 lists 0 for a colour JPEG Baseline image. pydicom's Pillow plugin (JPEG Baseline and Extended, JPEG 2000) returns color-by-pixel samples and lays them out by this element, so 0 is the value that reads them right. The
imagecodecsdecoders (JPEG Lossless, JPEG-LS, HTJ2K, and JPEG 2000 that Pillow cannot read) do not read it, so there the change is what the instance records and the export writes. Every export is decoded and written color-by-pixel (#1107), so 0 describes the written pixels in both arms. - By the owner's ruling, nothing checks the layout against the pixels, and there is no setting. A native file that is really stored color-by-plane and omits the element reads with its colours wrong.
- Not changed: an RLE Lossless image with no Planar Configuration is still refused in pydicom's words (RLE's decoder fixes its own layout; the exclusion is the ruling); a value other than 0 or 1 is refused under a native syntax and RLE, and a declared 1 is read as before there; a declared value under a compressed syntax is #1137's (below); an image of one sample per pixel gets no element.
CONFIG_VERSIONstays 2.0. - Tests changed: four existing test files asserted the refusal, under JPEG 2000, JPEG-LS, JPEG Lossless and a native 1-bit file. The two that pinned validation's
AttributeError(test_ingest_imagecodecs_fallback.py,test_a_plugin_value_error_reaches_the_fallback.py) now drop Bits Stored instead. The JPEG 2000 test intest_one_decode_answer_per_file.pynow asserts the read at every door and the note at export, and its JPEG-LSYBR_FULLrow moves to the new test's families; the 1-bit table loses itsno-planar-configurationrow, which the new test pins as #669's sentence. - Output: no existing golden-cohort member holds such a file (333 files read, top level and nested). A new member,
synthetic:no_planar_configuration, holds native files with the element absent and empty, JPEG Baseline and JPEG-LS files and a colour icon without it, and an RLE file that pins the exclusion. Recorded under both configurations: 5 of 6 ingested, and one accounting row besides the exports, the RLE file'sERRORrow in pydicom's words; noWARNINGorDATA_LOSSrow.(0028,0006)is 0 on the four colour images and on the icon item in each of the five DICOM arms (A.dicom,A.dicom-j2k,A.reopened.dicom,B.dicom,B.dicom-j2k), with the samples as built.fingerprint/output.jsonwas retaken on 3.12 and checked clean on 3.14t;compareagainstmain's file reports one difference, the member new in section 2, and nothing in sections 3 to 6.
- Now, where the element is absent or holds no value, ingest reads the samples color-by-pixel (0) and records 0 on the instance, so a reopened store,
- docs:
RELEASING.md's skip counts for 3.14t include the 17 real text-detector tests (#1121). "Cutting a release", step 4, gave 3.14t one skip fewer than 3.12, 3.13 and 3.14: 23 in a rehearsal, 22 in the publish run and 21 in a dispatch frommain. Since #1123,tests.ymlinstalls thetextdetectextra and setsISOCENTER_REQUIRE_TEXT_DETECTOR=1only on the entries that are not free-threaded, because OpenCV publishes no free-threaded wheel. So every test oftest_burned_in_text_with_the_real_text_detector.py(17) skips on 3.14t: 16 forNo module named 'cv2', one because the variable is not 1. 1.0.0rc20's runs showed it. Read from the last line of each shard'sRun Testsstep and added over a version's twelve shards, the rehearsal fromrelease/1.0(run 38914679748) skipped 24 on each of 3...
Isocenter 1.0.0rc20
Breaking
- A
burned_in_textpolicy must name itsscope; a block without one is refused (#1121, owner ruling of 2026-10-15). A configuration file that loaded under 1.0.0rc19 withburned_in_text: {action: redact}now raisesValueError: <file>: 'burned_in_text' needs a 'scope': one of flagged, modalities, allfromload_config(). A policy assigned in code (session.configuration.burned_in_text = {...}) raises the same sentence prefixedsession.configuration:fromredact(),export(),save()andcreate_config().- Why the old behaviour was wrong. rc19 read every image that holds pixels. On the MIDI-B Validation set that is about 24,000 images, nearly all CT, MR and PET slices.
redact()went from 0.1 s to 24 s on a 66-file sample, about 2.5 hours projected over the set. Two of the four text-free images it blanked at the default threshold were a CT scout and a PET slice. A default that reads everything is a cost and a risk the author should choose on purpose, so there is no default. - No final release is broken. The key shipped only in 1.0.0rc19, and no final 1.x reads a file without
scope. Adding a required key after 1.0.0 would refuse files 1.0.0 loads, which is why it lands now. - Every rc19 record is read again. The box rule changed (Changed, below), and the policy hash each record carries now holds the box rule's version (
services.BURNED_IN_TEXT_BOX_RULE = 2). So the firstredact()reads every in-scope image again, with noforce. Until it does, plainexport()withholds those images underredactandwithhold, with the existing "not checked … run redact()" row.
- Why the old behaviour was wrong. rc19 read every image that holds pixels. On the MIDI-B Validation set that is about 24,000 images, nearly all CT, MR and PET slices.
Added
burned_in_texttakesscope: flagged | modalities | all, andmodalities: [...](#1121, owner rulings of 2026-10-15). Each is spelled exactly. Every scope reads only images that hold pixels.flaggedreads an image whose Burned In Annotation(0028,0301)saysYES(stripped, any case; a value held asbytesis not read). It also reads an image that carries this store's redaction attestation, and one whoseburned_in_textrecord, under any policy, holds a box (owner ruling Q-A1 (a)).- Why the two extra arms. They close a fail-open. A zone redaction writes
(0028,0301) NObeside the attestation. Underaction: withhold, the pass reads aYESimage and records text the zone missed, and the zone then writesNO. Read by the flag alone, the nextexport()would find the image out of scope and write the textwithholdpromised to keep out. The same holds for an image a zone redacted before the policy was loaded. A record with no box does not keep an image in scope: a text-free image read underallfollows a narrower scope instead of being read forever. modalitiesreads whatflaggedreads, plus every image of a series one of whose Modality values is listed. A multi-valued Modality, held backslash-joined (#985), is split.modalitiesis required underscope: modalitiesand refused under any other scope. Each value must be an upper-case Code String (letters, digits,_, space, at most 16); lower case is refused, not folded. Repeats are dropped and the list is held sorted. The list must be upper case, but a source's Modality is matched upper-cased: a file that writescrfalls inmodalities: [CR](review of #1122).allreads every image that holds pixels, as rc19 did.- One predicate for the three readers. The
redact()pass, the plain-export gate andexport(check_burned_in=True)'s read all asksession._in_burned_in_text_scope. An out-of-scope image is not read, and the export writes it with no row, as it writes an image with no pixels. - The rows and section 5. The pass's
REDACTIONrow readsburned_in_text (action A, scope S, target T, min_confidence C): read R of N in-scope instance(s) holding pixels (O out of scope), …; undermodalitiesthe scope is writtenmodalities [CR, DX]. Section 5's line for a pass, and for an export's read, names the scope and the out-of-scope count.BurnedInTextSummary(tier 2) gainsscopeandout_of_scope, both with defaults, so one built by hand still builds and renders as before. - The grade. A pass that selects nothing costs no
PASS(owner ruling Q-A2 (a)); its row and section 5 say how many images were read and how many were out of scope. No grade condition is added. - MIDI-B. No file of 53,581 says
YES, soflaggedreads nothing there.modalities: [CR, DX, MG, US]reads 258 of the Validation set's 23,921 instances. scopeandmodalitiesare not in the policy hash. They choose which images are read, not which boxes a read finds, so a record read underallis current underflagged.CONFIG_VERSIONstays"2.0"(owner ruling Q-X1 (a), after Q9 on #782: rows freeze at final tags, and the key shipped only in rc19).scopeandmodalitiesare added to the 2.0 row ofSCHEMA_BY_VERSION. The row also gainsburned_in_text_values(theactionandscopevalue sets), so a new value is red as a new profile name is.BEHAVIOUR_BY_VERSIONdoes not move. Neither do the three_redaction_config_hashliterals: the hash shape is unchanged._burned_in_text_policy_hashmoves on purpose and is pinned by new literals.- Output: none for the golden cohort; no member holds the key, and
fingerprint/output.jsonis unchanged (thecheckresult is in the PR).
burned_in_texttakestarget: any | [names, dates, ids]: act only on the kinds of text named (#1121, owner rulings of 2026-10-15). Optional; the defaultanyis every box, as before (Q-B1 (a)). A list of kinds, spelled exactly, acts only on words read as one of them, and leaves the rest of the text in place.[names], never a barenames;anyalone, never in a list; an empty list, an unknown kind andnullare refused at load with a sentence naming the values. Repeats are dropped and the list is held sorted.- How a word is read. The words tesseract reads at
min_confidenceor more with two letters or digits (exactly the boxesanykeeps) are grouped into lines by where they sit: on one frame, a word joins a line when its height overlaps the line's by at least half the shorter of its own and the line's first word's (pixel_analysis.LINE_OVERLAP) and it overlaps none of the line's words side to side; each line is read left to right. The line's extent grows as it takes words, so a tilted line is followed; the height asked of a word does not. Without the side-to-side rule a smallPORTABLEreaching into a largeDOE, JANEwas readDOE, PORTABLE JANEand the name was left (review of #1124 at bc02c11). Not fixed: a word that sits in a gap of another line, under none of its words, still joins it and is read between them. Tesseract's(block, par, line)numbers are not used:--psm 11can split one visual line across blocks, andDOE,andJANEread apart are no name (review of #1124). Each line is read for the named kinds, and only the words a match overlaps are acted on, never the whole line (Q-B15 (a)). Nothing is compared with any patient's values: by owner ruling a burned-in name may be misspelled, and the source is not read again. - The kinds (new module
isocenter.text_kinds, which never logs).dates: day, month and year with one separator, either order or year first, 2- or 4-digit years 1800-2199, with day and month ranges checked;YYYYMMDD; an English month name beside a day or a year; OCR's digit look-alikes read as digits inside a candidate. Times of day are not dates (Q-B13 (a)).ids: a run of letters and digits, inner-/_allowed, of 6 or more characters holding 5 or more digits (Q-B14 (a)).names: spaCyPERSONentities fromen_core_web_sm, neverORG(Q-B12 (a)), on the line as read and title-cased, plus a^between letters. namesrequires spaCy (Q-B11 (b)); there is no fallback. Withnamesin the target and spaCy oren_core_web_smunable to load,redact()(after its drain and the OCR check, before any task, UID, row or zone) andexport(check_burned_in=True)(before the tag scan) raise the newpixel_analysis.NlpUnavailableError(tier 2), anOcrUnavailableError, namingisocenter[nlp]andpython -m spacy download en_core_web_sm.load_config(),save(),create_config()and plainexport()never need the model, so a policy stays portable. spaCy publishes no free-threaded wheel: on 3.14tnamescannot be used;datesandidsneed no model. The spaCy arm runs in no CI job and is tested with a stand-in model only, so it is not verified by CI (Q-B18 (a)).- A narrowed target never redacts a box
anywould not, pinned by a property test over every subset of the kinds. Somin_confidencehas one meaning under every target (Q-B17 (a)), and scope, the two-letters rule and the padding hold unchanged. - Rows and section 5. The pass's
REDACTIONrow names the target and, under a narrowed one,N region(s) read as names, D as dates, I as ids, K left in place(a region read as two kinds counts under each), and undernamesthe spaCy and model versions. Each image read with text left in place gets oneRISKrow,burned_in_text: K text region(s) on frame(s) F of instance UID were read as none of names, dates (target) and left in place., which does not grade (Q-B16 (a)). The read after redaction counts only text of a named kind, so a label left on purpose writes noWARNINGrow. Section 5 says the same counts.BurnedInTextSummarygainstargetandkinds, with defaults. export(check_burned_in=True)reads with the same classifier, needs no identifier, and so reads as well afteranonymize().- What is never stored or quoted. The worker returns boxes and counts; no text, span or per-word kind leaves it. The record holds the counts and, under
names,names_by(the spaCy and model versions); nothing read is hashed. An exception raised while the ki...
- How a word is read. The words tesseract reads at
Isocenter 1.0.0rc19
Breaking
- A
serial_number: "*"machine rule now covers series with no Device Serial Number too; it covered only series with one (#1108, owner ruling of 2026-10-14). No call raises and no signature changes. The same configuration file now zeroes its"*"zones on every serial-less series, inredact()(which also gives each such image a new SOP Instance UID, Image TypeDERIVEDand Burned In AnnotationNO, as for any redacted image) and in the export's zones, andscan_pixel_content()reads those series where it skipped them as "machines no rule names". A CT or MR stack whose files carry no serial is now redacted by a"*"rule its author wrote with serial-numbered devices in mind: read your"*"zones before the nextredact()orexport().- What was measured on
mainat f31758d, on 3.12.14 and 3.14.7t alike, with pydicom'sRG3_UNCR.dcm(a Fuji CR whose files carry no Device Serial Number) and a patient's name burned into its pixels at a known box: under a configuration whose only rule wasserial_number: "*"with a zone exactly over the name,redact()returned 0 ("No image matched any loaded rule's serial_number."), the export wrote all 1,498 ink pixels of the name,export(check_burned_in=True)wrote them too,scan_pixel_content()scanned nothing, and the report gradedPASS. - Why the old behaviour was wrong.
"*"was documented as "every series that has one", andservices.rule_applies_toanswered False for any series without a serial whileMachinePixelIndexindexed only series with one, so no rule at all could reach a device that does not write its serial. 34 of the 35 burned-in images in the MIDI-B Validation set the owner measured are such series. A wildcard that silently skips the devices most likely to burn text in left a burned-in name in an export gradedPASS. - Every door reads it one way:
redact(),redact_by_machine("*", roi), the export's zones, the store-wide icon gate,scan_pixel_content()andpreview_config()(which counted a"*"rule as matching nothing, because it looked the literal*up as a serial).discover_redaction_zones()compares its argument exactly, so"*"is still a literal there. - Attestation: an image covered by
"*"alone is hashed as before ({"serial": "*", ...}), so no image already redacted under a"*"rule is redacted again; the serial-less images are redacted for the first time. CONFIG_VERSIONstays"2.0"and the behaviour digest's 2.0 row is re-pinned (BEHAVIOUR_BY_VERSIONandSHIPPED_BEHAVIOUR), under the owner's ruling Q9 on #782 that rows freeze at final tags. This is a behaviour change of an existing file, named here: the digest's"*"scenario now redacts its five serial-less CT_small images. No(0012,0063)short hash moves: machine rules are not in the policy fingerprint.- Output: none for the golden cohort. Its configurations hold one exact serial rule and no
"*"rule;fingerprint/output.jsonis unchanged (thecheckresult is in the PR).
- What was measured on
Added
- A
burned_in_textconfiguration key:redact()reads every image that holds pixels with OCR and redacts, reports or withholds the text it finds, serial or not (#1108 part B, owner rulings of 2026-10-14). Until now OCR ran only when called (scan_pixel_content(),discover_redaction_zones()), only over machines named by serial, and never changed a pixel; a series with no Device Serial Number, and text that moves from image to image, reached the export as drawn. Onmainat f31758d, pydicom'sRG3_UNCR.dcm(a CR with no serial) with a name and a date drawn on its border was exported with every ink pixel underexport()and underexport(check_burned_in=True), and gradedPASS.- The key.
burned_in_text: {action: redact | report | withhold, min_confidence: 80}.actionis required and spelled exactly;min_confidenceis a number from 0 to 100, default 80;nullis absent. An unknown key, another spelling or a value out of range raisesValueErroratload_config(), naming the key. It is held onsession.configuration.burned_in_text(tier 2), written bysave()andcreate_config(), and is not in the policy fingerprint: no(0012,0063)value and no recorded PHI status moves. CONFIG_VERSIONstays"2.0"(owner ruling): the key is added to the 2.0 row oftests/test_config_schema_version.py'sSCHEMA_BY_VERSION. A file without the key is applied exactly as before; a 2.0 build older than this one refuses a file holding it as an unknown key.redact()runs it and records what it read.export(check_burned_in=True)also reads the pixels under the key, and records nothing (the Changed entry below). Workers read each image with OCR after zeroing the zones its machine rules hold, on a copy, and hand back box coordinates only, never text; the parent records the boxes, the tesseract version and what they were read under (min_confidenceand the zones, not the action) on the instance, so an image is read once until those change orredact(force=True), which reads again and keeps the earlier boxes. Underredacteach box is blanked on its own frame, widened by 15% of its height on every side (at least 2 pixels), in the same task as the zones; the new SOP Instance UID is derived in the parent under the project secret from the source UID, the zones and the boxes; the image is read once more.redact_by_machine()does not run it. With the key present and OCR unavailable,redact()raisesOcrUnavailableErrorafter its entry drain and before any task, zones included; with no rules and no key it still returns 0 before the drain.- What an OCR-only redaction writes (owner ruling Q5):
DERIVEDand derivation code113062, and Burned In Annotation(0028,0301)as the source had it, because OCR finding nothing does not show an image holds no text. An image a zone also redacts getsNOas before. - The record is tied to the pixels it read (owner ruling on the review of #1109). It holds a sha256 digest of what the read saw: the dtype, the shape and the bytes, after the zones are zeroed on a copy, so a zone's own redaction leaves it current. A redacted image's digest moves to its redacted pixels with the read that follows the redaction. Pixels set afterwards, by
set_pixel_data()or any other write, are pixels no read saw: the export treats the image as never checked, and the nextredact()reads it again withoutforce. NotInstance._pixel_hash, which hashes the bytes alone and names the frame the store holds, so between aset_pixel_data()and the next save it still names the frame that was replaced.min_confidenceenters the record's policy hash as a float, so80and80.0are one policy. - Plain
export()reads the record and runs no OCR (export(check_burned_in=True)reads the pixels instead, in the Changed entry below). It loads each image's frame once, with no lock held, to check the digest of a record that is otherwise current. Underredactandwithhold, an image that holds pixels and was never read under the policy's threshold and zones, or whose pixels changed since, is withheld; underreportit is written. An image with text is withheld underwithhold, withheld underredactuntil a redaction carrying its boxes has landed, and written underreport. An image with no pixels is never read, gated or counted. TheEXPORTrow's text gains "; N more withheld by the burned_in_text policy" when the policy withheld any; the text is unchanged otherwise. - Rows, all naming counts, frames and UIDs, never what OCR read. One
REDACTIONrow per pass, entityburned_in_text:burned_in_text (action A, min_confidence C): read R of N instance(s) holding pixels, U could not be read, S already checked under this policy; text found on T (K region(s)); …. OCR by tesseract V.WARNINGrows:burned_in_text: K text region(s) found on frame(s) F of instance UID; not redacted (action: report).;burned_in_text: K text region(s) still found after redaction on frame(s) F of instance UID.; an image the pass could not read, inredact()'s words for an unread instance; and at export,DICOM export to D withheld|did not withhold instance UID: its pixels were not checked for burned-in text under this configuration's burned_in_text policy; run redact().,… withheld instance UID: burned_in_text found K text region(s) on frame(s) F (action: withhold).,… that no redaction has blanked; run redact().… did not withhold instance UID: burned_in_text found K text region(s) on frame(s) F, not redacted (action: report).("did not withhold", not "wrote": the row is written before the file is), andburned_in_text: the read after redaction could not read instance UID in full, so its redacted pixels are not checked under this policy.A failure is quoted as its frame and exception class only when it happened while OCR ran, because tesseract's own message is not promised to hold no text it read. - The grade (owner ruling): no new grade condition. A
redactpass that blanked text costs noPASSby itself; eachWARNINGrow above does, under condition 2. Section 5 gains one line per pass, and its pixel-scan line no longer says no OCR ran when a pass did. - What OCR misses is on the OCR page: faint and very short text, text over anatomy read in fragments, and wanted labels blanked with the rest. Use
reportfirst on a cohort you have not looked at. - Output: none for a configuration without the key; no cohort member holds it. With the key, under
redact: the pixels of each image with text found, its SOP Instance UID (and so its file name), and its Image Type and Derivation Code Sequence; Burned In Annotation is left as the source had it, and no(0012,0063)value moves. Underwithhold, and underredactorwithholdfor an image nev...
- The key.
Isocenter 1.0.0rc18
The Breaking entries are given whole. Under Changed and Fixed each entry is given by its headline only, because the full section is longer than a GitHub release may hold: the measurements, the Output: lines and what was not changed are in CHANGELOG.md at this tag.
Breaking
- A report over a store that has been audited grades
REVIEW_REQUIREDwhile the store holds a patient, study or instance no scan has read; it gradedPASS(#1064, owner rulings of 2026-10-11). This is a ninth grade condition. No call raises and no signature changes:generate_report()writes a different grade and one more Grade Basis line for the state below.-
What was measured on
mainat 7325ff3, on 3.12.14 and 3.14.7t alike. File A is ingested, audited, anonymized and saved. File B is ingested afterwards.export()andgenerate_report()follow with no furtheraudit(). B is written as the store holds it, which is as ingested, and the headline grade wasPASS. With B a new study or another patient, its file holds the source's name, Patient ID, birth date, study date, Accession Number, institution, physician, Study ID and UIDs. With B a new series of A's study the file looks de-identified: the export stamps the pseudonym, the replaced Patient ID, the shifted Study Date and the replaced Study Instance UID from the owners, and the Accession Number, birth date, institution, physician, Study ID and the Series and SOP Instance UIDs are the source's. Read against the file ingested, those four stamps ((0010,0010),(0010,0020),(0008,0020),(0020,000D)) are the only top-level elements that differ. -
Why the old grade was wrong. The headline said
PASSover files the run had not read, in a store whose other files it had. Four things already told the truth about B and still do: its file carries no(0012,0062) YES; section 5 counted it (1 of 2 instance(s) have no PHI scan at their current revision); the manifest saysanonymized: false; anddocs/analytics.mdstated the rule, "Data never scanned does not grade". The grade was the one thing a reader takes first, and it disagreed with all four. -
The condition. In a store where any patient, study or instance holds a recorded PHI status, each patient, study or instance that holds none costs the
PASS. The Grade Basis line, verbatim for the first row of the table:3 entities never scanned, in a store that holds a recorded PHI status: no PHI status is recorded on it, by a scan or by a pass, so nothing says what the policy names was looked for in it; `audit()` reads it (patients 1, studies 1, instances 1)(1 entityfor one). It names counts and never a value, a UID or a path. It is read from the store when the report is generated, as conditions 7 and 8 are, so:- it holds before any export (a report generated before
export()already says it) and after a save and a reopen; - it is counted over the whole store, so an export whose
subsetorpatient_idsleft the unscanned entity out still grades, as condition 7 does for anIDENTIFIEDentity; - it clears when a scan reads the entity.
audit()alone turns the line into condition 7's;anonymize(audit())and a second export gradePASS. NoWARNINGrow is written at export, because a row stays in the audit log for good and would grade every later report of the store after the remedy.
- it holds before any export (a report generated before
-
"Holds a recorded status" is a status of any value on any patient, study or instance, including one an edit has left stale (condition 8). It is read from the raw record, which is what the store keeps: a stale status is stored (
phi_status_edited, #767) and comes back on a reopen, so a store whose every status is stale still counts as audited, in the session that scanned and in the next. A Series and a nested item are neither read for it nor counted. In memory a pass leaves a status on a Series, and the store has no column for one; a rule that read it would grade a store differently from its own reopened self. Conditions 8 and 9 are disjoint (a status left behind by an edit, against no status at all), and between them they are exactly the instances section 5 counts. -
What still says
PASS, and why. Ruling Q3 on #573 stands for a store nobody ever audited:ingest()thenexport()with noaudit()gradesPASSas before, under the floor policy, underprivacy_profile: none, under aKEEPconfiguration, after a reopen, and for WFDB. An unscanned instance there is the absence of a measurement and not a finding, and a format conversion is not a failed de-identification. Section 5 states the count either way. The condition is therefore gated, and the gate is the difference between the two cases: forgettingaudit()for one late file gradesREVIEW_REQUIRED, and never calling it at all does not. -
Measured before and after, each shape through
ingest(),audit(),anonymize(),export()andgenerate_report()on files written from pydicom'sCT_small.dcm(waveform_ecg.dcm, cut to one multiplex group, for WFDB), the grade read from the report file. Identical on 3.12.14 and 3.14.7t.RRisREVIEW_REQUIRED; "x > y" is a report before and one after.Shape Statuses at the report (instances) mainNow Line 9 counts (patients, studies, instances) r1: A passed and saved, B a new study, export 1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 r2: the same, B a new series of A's study 1 REMEDIATED, 1 unscannedPASSRR 1, 0, 1 e01: the same, B another patient 1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e06: r1 under privacy_profile: none1 CLEARED, 1 unscannedPASSRR 1, 1, 1 e07: r1, saved and reopened, then export 1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e08: r1, export(subset=…)leaving B out1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e09: e01, export(patient_ids=…)leaving B out1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e10: WFDB, A passed, B another patient 1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e12: r1 through write_tree(), then a report1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e13: r1, a report before the export and one after 1 REMEDIATED, 1 unscannedPASS>PASSRR > RR 1, 1, 1 e17: r1 with no export at all 1 REMEDIATED, 1 unscannedPASSRR 1, 1, 1 e15: A audited and not passed, B a new study, export 1 IDENTIFIED, 1 unscannedRR (condition 7) RR (7 and 9) 0, 1, 1 e19: r1 exported, then anonymize(audit())and a second exportthen 1 REMEDIATED, 1CLEAREDPASS>PASSRR > PASS1, 1, 1 then none r3: r1, then audit()only1 CLEARED, 1IDENTIFIEDRR (condition 7) RR (condition 7) none r4: r1, then anonymize(audit())1 REMEDIATED, 1CLEAREDPASSPASSnone e23: r2, then anonymize(audit())all REMEDIATEDorCLEAREDPASSPASSnone e24: e01, then anonymize(audit())all REMEDIATEDorCLEAREDPASSPASSnone e02: no audit()at all, floor policy2 unscanned PASSPASSnone e03: no audit(),privacy_profile: none2 unscanned PASSPASSnone e21: no audit(), aKEEPconfiguration2 unscanned PASSPASSnone e18: no audit(), saved and reopened2 unscanned PASSPASSnone e11: WFDB, no audit()2 unscanned PASSPASSnone e20: a KEEPconfiguration,audit()only2 CLEAREDPASSPASSnone e22: e20, saved and reopened 2 CLEAREDPASSPASSnone In every row that moved, line 9 is the only reason, except e15, where condition 7's line stands beside it. r2's
patients 1is not B's patient, which is A's and holds a status: that ingest leaves a second patient in the store under the source Patient ID, holding no studies (#1065, not fixed here), and a patient with no recorded status is counted whether or not it holds a study.audit()records a status on it andanonymize()merges it away (e23). -
Section 5's PHI Scan line said such instances "are not graded". With the gate open that is now false, so the line's second sentence says which case the report is in:
Each was never scanned (grade condition 9) or was edited after its scan (condition 8), and the Grade Basis counts it.in a store that holds a recorded status, andNo PHI status is recorded on any patient, study or instance in this store, so they are not graded: an entity never scanned costs the PASS only in a store that holds a recorded status (grade condition 9).in one that holds none. The first sentence, with the count, is unchanged.ComplianceReport(tier 2) gainsnever_scanned, the per-level counts, andphi_status_recorded, the gate. -
Not a
CONFIG_VERSIONminor. The rule for a minor is the same file applied differently to the same input: findings, values a rule writes, tags a rule reaches, pixel zones or date jitter (owner ruling on #762). A grade is none of those. No configuration file is read differently, no rule raises another finding or writes another value, and the policy fingerprint in(0012,0063)does not move. Conditions 7 and 8 were added at2.0on the same argument. -
The stability promise allows it: no condition is removed or narrowed in 1.x, and one may be added with a CHANGELOG entry (
docs/api/stability.md, Output vocabularies). The grade's two values keep their spelling. -
Output: none. No exported byte changes and no audit row is written or withheld; only the report's grade, its Grade Basis line and section 5's PHI Scan sentence change.
fingerprint/output.jsonis not retaken:scripts/output_fingerprint.pyrecords exported...
-
Isocenter 1.0.0rc17
Changed
-
ci: the tesseract install retries its download inside the step, and the runners fetch every tag and
origin/main(#984, #966, owner rulings 2026-10-08). Two things a release run did that its green did not show.- #984: a stalled mirror cost a rerun at both rc15 runs.
Install tesseractwas oneapt-get installunder a 3-minute cap. On 2026-10-07 the Ubuntu mirror left some runners' connections to apt's 30-second timeout, package after package, and the cap fired with the install still waiting: six of 32 jobs at the rc15 rehearsal (run 37677947792) and three of 32 at the rc15 publish (run 37681895889), one of them a floor job, so the upload was skipped untilgh run rerun --failed. No test had run. Jobs that got through that day took up to 173 s, against a median of 13 s over 80 jobs of four runs. - The step now downloads in a loop: up to four attempts of
apt-get install --download-only, each undertimeout(75 s), 15 s apart, then oneapt-get install --no-downloadfrom apt's cache and the version check as before. The bound is the retry: the installs stalled rather than failed, so a loop around an unbounded attempt would never have reached a second turn. dpkg runs outside the loop and under notimeout, so a bound cannot fire inside it.apt-get updateis bounded too (45 s) and is still allowed to fail. Worst case 415 s; the step's cap goes from 3 minutes to 8 and the job's from 45 to 50, the same 5-minute margin over the step sum. - #966: 43 of the 61 tests a release run skipped needed git refs the runner did not have.
actions/checkoutfetched one commit: no tag, and offmainnoorigin/main.tests/test_released_changelog_sections_stay_as_released.py's comparisons with each tag and withorigin/mainskipped (42 at the rc15 publish from its tag; 82 at its rehearsal from the branch, by the issue's count), and so didtest_no_row_the_previous_final_release_shipped_has_moved, the check RELEASING says stops a release when it skips for want of tags. The issue's "13 to 20" was a local figure: the same set skipped at rc12 (57 on 3.12), rc13 (58) and the #964 dispatch (58), and #970's second comparison per section took the rehearsal to 101. The checkout now passesfetch-depth: 0. - What still skips on a runner, read from the four runs' logs and each test's skip: 12 parametrized rules
set_phi_tagcannot spell, 4 that needcoverage(thedevextra, whichtests.ymldeliberately leaves out), 2 by platform (1 on 3.14t), 1 until a final release carries the configuration behaviour pins, and the section being released (2 in a rehearsal, 1 in the publish run). RELEASING, "Cutting a release", step 4, now gives the count per kind of run, 21, 20 and 19, and says any other number is a finding. It also says what a job red inInstall tesseractmeans now. It is not a test result. Attempts cut off at their bound, about seven minutes in all, are a mirror out of reach, rerun once and then waited for; four attempts that fail at once, in under a minute, are apt being refused something, and its error is read before any rerun. A redtest-floorjob held the upload back and a redtest-supportedjob alone did not. Such a red does not spend the one rerun of the embargo procedure's Disclosure step 5 (#867), which stays for a test failure (owner ruling, 2026-10-08). - The other open question on #966: the runners collected 7,921 tests where a local run at the same commit collected 7,920. The one is
tests/test_pixel_dtype_roundtrip.py::test_set_pixel_data_refuses_a_dtype_the_sidecar_cannot_carry[float128]: numpy hasfloat128on Linux x86-64 and not on macOS arm64, and the test adds the case where the type exists. tests/test_packaging_contract.pyruns the step's script withsudo,timeout,apt-get,sleepandtesseractreplaced by recorders: one to three failed downloads still install, with a pause after each; four fail the step; everyapt-getcall that reads the network is undertimeout, everytimeoutis run bysudoand runs nosudoitself, aninstallundertimeoutcarries--download-only, and the one--no-downloadinstall is under none; and the bounds and pauses, added up, fit the cap. The order ofsudoandtimeout, and the loop's--download-only, had no pin until the review of #1005 found the two mutants green.test_the_gate_checkout_brings_the_tags_and_mainis red withoutfetch-depth: 0.- Not verified on a runner by this change's tests: what the full fetch costs the checkout step (the repository is a 43 MiB pack; the 3-minute cap is kept), and that every test the refs wake passes there as it does locally.
- Output: none. No file under
isocenter/changes.
- #984: a stalled mirror cost a rerun at both rc15 runs.
-
docs: two limits are written down where a user would meet them (#772, #756, owner rulings 2026-10-08). Neither changes behaviour; each was measured on
mainat fd359eb on 3.12 before it was written.- #772, in
docs/migration.md, "UIDs in stores and exports from before 1.0": a 0.9.x export of a redacted instance, re-ingested into its own store after 1.0'sanonymize()has replaced that store's UIDs, is admitted as a new instance. Measured with an instance shaped as 0.9.x redaction left it (a random1.2.826.0.1.3680043.8.498.…UID, its source UID recorded once): afteranonymize()it holds its2.25.replacement; ingesting a file that carries the random UID leaves two instances and writes noWARNINGrow, because the store knows the instance by its replacement and by its source UID only. Ingesting the source file again is declined, with theWARNINGrow that names the instance holding it. It does not stay two instances (review of #1007, measured again at that head): after the nextanonymize()both hold one SOP Instance UID, the store has oneinstancesrow for the two, andexport()writes one.dcmfile with anERRORrow, "2 exported instances share SOP Instance UID … and were written to one file". The page says that too. Documented as a limit under the ruling that nothing written before 1.0 is accommodated; the page says to re-ingest source files. - #756, in
docs/configuration.md, "Whatbasic@2026ccontains": a presentation state whose Bitmap Display Shutter names an overlay group keeps its Shutter Overlay Group(0018,1623)while60xx,xxxxremoves the group. Measured on a synthetic Grayscale Softcopy Presentation State (pydicom'sCT_small.dcmrelabelled,BITMAPshutter, one overlay plane in group 6000; no real GSPS was used): under the floor the export holds(0018,1623)=0x6000and no element of group 6000; with"60xx,xxxx": {action: KEEP}it holds all seven overlay elements. The page says PS3.15 Annex E is the authority on what the profile removes: for the overlay group, E.1.1's sentence on overlays, which the bullet above it already quotes ("If non-pixel data graphics or overlays contain identification, the de-identifier is required to remove them", read again in the 2026c edition, where it continues "or clean them if the Clean Graphics Option is supported"), and Table E.1-1 for every other attribute (owner ruling, 2026-10-08: the group rule is one the table does not have, so the table is not cited for it). It says that Isocenter neither keeps the group for the shutter's sake nor removes the shutter, and thatKEEPon the group is how to keep such an object valid. - Output: none. Two pages change.
- #772, in
-
Assigning
instance.sop_instance_uidalso sets the instance's(0008,0018)element, so the export writes the file under the assigned UID (#936, owner ruling Q1 A).Instance.sop_instance_uidis tier 1. The export names a file by the field and fills it fromattributes, and pydicom's writer then sets Media Storage SOP Instance UID from the dataset's own(0008,0018)whenever that is non-empty.Instance.__setattr__moved the field and the revision and left the element. Every library writer that moves the UID sets both (UID replacement, redaction, the redaction withdrawal); only a caller's own assignment left them apart. Measured onmainat fd359eb, two CT files ingested and saved, then one assignment:instance.sop_instance_uid = '1.2.3.4': the export wrote1.2.3.4.dcmcarrying the earlier UID in(0008,0018)and in Media Storage SOP Instance UID, a file whose name and content disagree, with no row;written_uidsheld'1.2.3.4'.instance.sop_instance_uid = '': the export wrote the dotfile.dcm, carrying the earlier UID, with''inwritten_uids, no failure and no row.- A save and a reopen gave the field and the element apart, as they were: the row was keyed on the field and its
attributes_jsonheld the earlier UID. - Now the assignment writes
attributes["0008,0018"]first, then the field, then the revision, once.1.2.3.4.dcmcarries1.2.3.4in both elements, and a reopened store gives the two together. An emptied UID is not this entry's any more: since 1.0.0rc16 the export's file-name rule (io_handlers.export_file_name, GHSA-2rc2-r9r5-x7hm) refuses a UID that cannot name a file before anything is written, with or without this change. Measured on the merged tree:ExportSummary.failuresholds('UNKNOWN', 'Export failed for an instance with no SOP Instance UID: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it is empty. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else.'), oneERRORaudit row keyedUNKNOWNcarries the same text,written_uidsnames the other instance only, and no file is written for it. What this change adds there is that the element is empty too.export()raisesExportErroronly when no instance at all reaches disk, as before. - No new exception, and nothing new is refused.
''is still not refused by the save (#721 refuses a UID that is not astr).Nonefollows the field into the element, and the ...
Isocenter 1.0.0rc16
This release carries one security fix and nothing else. No commit is picked from main. Exported output changes for one kind of instance only, as the entry's **Output:** line says: an instance whose SOP Instance UID is not text, is empty, holds anything but letters, digits, _, - and ., or begins with ., is no longer written. fingerprint/output.json is as 1.0.0rc15 left it; the **Output:** line says why no member of the reference cohort can show the change, and that adding none is the owner's exception.
Security
export()could write a file outside the export folder, and over a file already there, when an instance's SOP Instance UID held a path separator (GHSA-2rc2-r9r5-x7hm). Every release from 0.7.0 to 1.0.0rc15 named each exported file<SOP Instance UID>.dcmand joined that name to the series folder as it stood. The three folder names go throughConfigLoader.clean_filename; the file name did not.ingest()accepts a file whose(0008,0018)is not a conformant UID, so a file from anywhere else chose the name.- Measured on 1.0.0rc15 (the wheel from PyPI on Python 3.12.14, and the tree at 33d8811 on 3.12.14 and 3.14.7t, pydicom 3.0.2). pydicom's
CT_small.dcmwith(0008,0018)set to../../../../../escaped, ingested beside one unchanged file (ingested=2, no failure), thenexport("work/export_here/out")with noanonymize():- the file was written as
work/escaped.dcm, two directories above the export folder; - the export reported it in
written_uids, with no failure, no audit row, andwrote 2 of 2 planned instances; - a file already at
work/escaped.dcmwas replaced, because the write is a rename into place; - a UID of
../../../../../newdir/sub/escapedcreatedwork/newdir/sub/and wrote into it; - a UID that was an absolute path was written at that path.
The suffix is always.dcm, and the content is the exported dataset.
- the file was written as
- The same through a UID that is not text. A file that states
(0008,0018)with VROBorOWis held asbytes, and the name was the text of that value, quotes and all:b'../../../../../../../escaped'.dcm. Its first component,b'.., is a directory the write created inside the series folder, and the rest climbs out of it. Measured: that file, exported towork/export_here/out, was written aswork/escaped'.dcm.session.export(), 0.7.0 to 1.0.0rc13 (measured on the wheels of 0.7.0, 0.9.8, 1.0.0rc1 and 1.0.0rc13, with noanonymize()): written outside the folder, and from 0.9.8 thebytesvalue was listed inwritten_uids.audit()andanonymize()under the default configuration replaced it from 1.0.0rc1, as they do a UID of text (measured on 1.0.0rc1 and 1.0.0rc13).session.export(), 1.0.0rc14 and 1.0.0rc15: not reached. Since #721 a save refuses a UID that is not astr, soingest()of that file raisesValueError("nothing was saved"), and so does the save thatexport()begins with (measured on both wheels).DicomExporter.write_tree(), through 1.0.0rc15: written outside the folder, and the call returned normally (measured on 0.9.8, 1.0.0rc1 and 1.0.0rc15). It has no save in front of it. On 1.0.0rc14 and 1.0.0rc15 that takes a caller who goes on afteringest()raised, where the instance is still insession.store, or one who fills aDicomStorewithDicomImporter.import_filesand no session.- A UID the caller assigns as
bytesor as a list did the same atwrite_tree()(measured on this branch before the rule judged the type;write_tree()there is 1.0.0rc15's for such a value).
- Which calls, in which releases. For a UID of text. Measured on the wheels of 0.7.0, 0.9.8, 1.0.0rc1 and 1.0.0rc15; the releases between them are read, not run.
- 0.7.0 to 0.9.8: every
export()of such a file, with or withoutanonymize(). No release before 1.0.0rc1 replaced the SOP Instance UID: on 0.9.8 and on 0.7.0,audit()andanonymize()under the default configuration left it as ingested, and the export wrotework/escaped.dcm. (0.7.0 was run withuse_compression=False: its default compressed export fails for every file in a 2026 environment, for an unrelated reason, having planned the same path.) - 1.0.0rc1 to 1.0.0rc15: an export of an instance that still holds the source's UID. That is an
export()with noanonymize()before it, and anexport()after a fullaudit()andanonymize()under a configuration that keeps(0008,0018):privacy_profile: none, or aKEEPrule on the tag (both measured). It is also a UID the caller assigns (instance.sop_instance_uid = ..., measured). - Not affected, from 1.0.0rc1:
audit()andanonymize()under the default configuration, which replace the UID with a2.25.one (#544; measured on 1.0.0rc1 and 1.0.0rc15;basic@2026ccarries the same rule, read), and an instanceredact()rewrote, which takes a derived2.25.UID (measured). DicomExporter.write_tree(), from 0.9.0, always: it applies no policy. Measured on 0.9.8, 1.0.0rc1 and 1.0.0rc15. Up to 0.8.2 that door, thensave_patient, named files by Instance Number (0.7.0 measured:0000.dcm, inside the folder).- 0.6.0, tagged under the name
gantryand never uploaded asisocenter, cleaned the name at the session's door (read). Before 0.7.0, 0efc25d replaced that call with a name built from Instance Number, and a later commit went back to the UID without the cleaning, under the comment "Use SOP Instance UID for filename to ensure uniqueness and match tests". 0.7.0 is the first upload to PyPI, so every published release has the defect.
- 0.7.0 to 0.9.8: every
- What it let someone do. Whoever can get a crafted DICOM file into a folder a user ingests and then exports decided where one
.dcmfile was written on that user's disk, within what the process may write, and could replace a file there. Nothing told the user: the file was counted as written. - How it was found. By the maintainers, while measuring another issue. No report is outstanding.
- Now. One helper,
io_handlers.export_file_name, builds the name for both doors, and refuses a UID that cannot name a file. Nothing is cleaned: the UID is also what the file carries in(0008,0018)and whatwritten_uidsreports, and a name that differed from it would be a second answer to which file is which (owner ruling, 2026-10-08).- The rule. A UID is refused when it is not a
str, when it is empty, whenConfigLoader.clean_filenamewould change it, or when it begins with., asked in that order.- Not a
str:bytes, a list,None, a number. The type is judged before the text, because the text of abytesis its repr. Astrsubclass, which is how a UI value is held, is text. - Empty: the name would be the hidden file
.dcm(owner ruling, 2026-10-08). clean_filenamewould change it: surrounding whitespace, a space, and every character that is not a word character,-or.: so/,\, NUL, a newline or any other control character, and each of:*?"<>|. A word character is Unicode's\w: a letter of any script, a digit or other numeric character (½,²,Ⅷamong them), and_. None of them is a separator.- Begins with
.: that refuses.,..and any name a directory listing hides.
- Not a
- A conformant UID is never refused, and its file name is the UID and
.dcm, byte for byte as before. Neither is a non-conformant UID of safe characters (SOP1,sop-1_a). session.export()writes no file for a refused instance, inside the folder or outside it, and creates no directory for it. The instance is inExportSummary.failuresunder its UID, with oneERRORrow keyed on the UID:Export failed for instance <UID>: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it holds a character other than a letter, a digit, '_', '-' or '.'. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else. …. The row names the instance by its UID, as every export row does, and never a path. The report gradesREVIEW_REQUIRED. The other instances are written, and theEXPORTrow counts the refused one as planned (wrote 1 of 2 planned instances). An export in which nothing else was planned raisesExportError, as any export that delivers nothing does.- An empty UID is refused the same way, and its failure and its
ERRORrow are keyedUNKNOWN, as every export row keys an instance with no UID (measured):Export failed for an instance with no SOP Instance UID: FileNameRefused: SOP Instance UID (0008,0018) cannot name a file: it is empty. The export names each file by that UID, so no file was written for this instance, in the export folder or anywhere else.No source file arrives so:ingest()refuses a file with no SOP Instance UID. It is a UID the caller assigns, or a hand-built instance. - A UID that is not a
strreads… cannot name a file: it is held as bytes, not as text. …, naming the type and never the value. Atsession.export()the save's refusal (#721) still comes first and raises; the rule's is behind it. DicomExporter.write_tree()refuses the same UIDs, the ones that are not text and the empty one included. It writes the other instances and then raises itsRuntimeError, as for any instance it cannot write:Export incomplete. 1 failed. First error: SOP Instance UID (0008,0018) cannot name a file: ….- Measured after, on both interpreters, over the same inputs: no file and no directory outside the export folder in any case above; the file already at
work/escaped.dcmis byte for byte what it was; the unchanged file beside the refused one is written under its own name.
- The rule. A UID is refused when it is not a
- What this does not cover, and what was checked and found sound.
- The folder names were not a second door. A Patient ID, Study Description, Series Description, Modalit...
- Measured on 1.0.0rc15 (the wheel from PyPI on Python 3.12.14, and the tree at 33d8811 on 3.12.14 and 3.14.7t, pydicom 3.0.2). pydicom's
Isocenter 1.0.0rc15
Exported output changes in this release in WFDB exports only, as the two entries' **Output:** lines name, where the details are. compare --base v1.0.0rc14 reports one new member of the reference cohort, synthetic:ecg_lead_codes, and nothing else; no existing member's output changes:
- #832: in a
.hea, the description field of a signal line, and inannotations.jsontheleadof a finding on that channel, change for three kinds of channel. A lead code with whitespace in front of it is written as the lead's name (2:1toI). AnMDC 2:3channel whose Code Meaning says Lead III is writtenIIIwhere it wasV1. A table-named channel whose name another signal of its record shares is written as its Code Value (IIto2:2), with one newWARNINGrow for that record, which grades the runREVIEW_REQUIREDwhere it gradedPASS. - #963: in
annotations.json, a finding loses itsleadkey when its mark names a channel past the record's sample columns. No row is written for it and the grade does not move.
One entry corrects the 1.0.0rc14 record and changes nothing in this release: ten of that release's **Output:** lines said less than it does (#764, #679, #926, #775, #721, #733, #722, #747, #703, #937). Each is restated as measured against 1.0.0rc13. Where the 1.0.0rc14 section's lead and one of those lines disagree, the line is the measured one.
The nine other entries' **Output:** lines say none (#804, #687, #956, #754, #753, #800, #779, #931, #935).
Changed
-
docs: the waveforms guide's quick start runs as part of the test suite, and no longer sits under
if __name__ == "__main__":(#804, owner ruling Q4 A).docs/waveforms.mdcarries<!-- tutorial: inputs=waveform_ecg.dcm -->, sotests/test_tutorials_run.pyruns it whole over the 12-lead ECG bundled with pydicom, as it runsdocs/tutorials/*.md. The page's directory tree, a```textblock nothing checked, is replaced by shown output: the pathexport()returns, the three files beside the header, and the twelve lead names read from the.heatext.- Why the guard left the code. The tutorial runner executes a page's fences in a namespace whose
__name__is__tutorial__. Measured onmainat bb5e6f1 with the header added and the tree removed: the quick start's one fence, underif __name__ == "__main__":, ran in 0.0 s, did nothing and raised nothing, although the folder it ingests did not exist. One true>>>line elsewhere on the page would have made it green with its quick start never run. The page now shows the code without the guard and says in prose to put it under one in a script, with the link the tutorials use. - The runner refuses a fence under the guard.
_shape_failuresgains one rule, with the others for code the runner would not run: a python fence holding anif __name__ == "__main__":test (either operand order, in a plain fence or a>>>example) is red,a fence under the main guard does not run here. It reads the fence withast, so a comment or a string that quotes the guard is not one, andif __name__ != "__main__":is not one either. No existing run page trips it: the six tutorials name the guard in prose only. - Cost. One more page per run of the runner: 2.7 s on 3.12 and 1.4 s on 3.14t, measured.
pytest --changedselects the runner for any.mdchange. - Output: none. Documentation and one test rule.
- Why the guard left the code. The tutorial runner executes a page's fences in a namespace whose
-
tests: a relative link or image in the documentation that reaches no file is red before merge (#687, owner ruling Q5 A).
tests/test_doc_anchors.pygainsbroken_file_links, beside the fragment check, andtest_every_documentation_file_link_resolvesover the tree. The tree has no such link today (0 broken, measured at bb5e6f1 and on this branch).- What was true before. The issue said nothing caught a broken relative file link.
mkdocs build --strictdoes (mkdocs 1.6.1, measured over a 17-link probe page: 9 warnings, build failed), but it runs only indocs.yml, on a tag or a dispatch, so a broken link failed the release's documentation deploy, after the tag was cut. It also passes five kinds that are dead on the published site: an absolute/page.md, a link to a pageexclude_docskeeps off the site, a directory-styledir/, and raw-HTML<a href>and<img src>. And it never readsREADME.md,CHANGELOG.mdorRELEASING.md. - The rules, each named in the failure message:
[absolute];[no such file]; from a page underdocs/,[leaves docs/: not on the site]and[excluded from the site](the directoriesexclude_docsnames, read frommkdocs.yml; a pattern the check cannot read stops it); and[a directory]. A root page is held to the first, second and last against the repository, as GitHub renders it. - A target is compared with its directory's own listing, component by component. macOS's filesystem is case-insensitive, so
Path("docs/Waveforms.md").exists()is true there and the link is dead on GitHub Pages. - No second rendering. Both checks read one cached parse of each page (
_parsed_pages), so the link check adds 0.05 s to the file's run (measured on 3.12 and 3.14t; the file's 25 tests take 4.4 s on 3.12, of which the one rendering of the tree is 3.4 s). A test pins oneconvertper page over both checks. A fixture therefore builds its whole site before its first call. - Not covered, and said in the module docstring: a link inside a
pymdownx.snippetsinclude or a tab body, the cross-references mkdocstrings generates,naventries, the fragment of a link into a file that is not a checked page, and external URLs, which are never fetched. - Output: none.
- What was true before. The issue said nothing caught a broken relative file link.
-
The released-sections test also compares each section with
origin/main's, andRELEASING.mdsays what to check aftermainis merged into a branch and what a pick does toCHANGELOG.md(#956). Git placed changelog entries wrongly, with no conflict, three times on 2026-10-07. No package code changes.- A merge of
main. #958's branch (d7e1cc9) merged the 1.0.0rc13 record-back (5f0c1ca). The merge was clean, and three### Fixedentries, 37 lines, landed at the end of[1.0.0rc13]; the developer moved them back by hand. Reproduced withgit mergeand withgit merge-fileover the three real files, which give the same bytes. - What the test of #927 did with it.
tests/test_released_changelog_sections_stay_as_released.pycompared each released section with the newest present tag that holds it. Over that merge it is red wherev1.0.0rc13is present, and where it is not it skips[1.0.0rc13]and passes: 45 passed, 1 skipped. The newest tag is the one a clone most often lacks, because release tags sit onrelease/X.Y, whichmaindoes not reach, sogit fetch origin mainbrings the record-back and never its tag. Whether the tag was present in that branch's clone at the time cannot be told now.RELEASING.mdalso never said to run the test after mergingmain. - Now each released section is also compared, by the same rule (byte-identical, or rewritten in place), with the same section of
refs/remotes/origin/main:CHANGELOG.md, which needs no tag. Over the same merge withv1.0.0rc13absent: 1 failed, naming[1.0.0rc13]with "106 lines where the release has 69" and the diff; over the hand-fixed merge (3f72c1d's file): 85 passed, 1 skipped. - What it cannot see, and skips by name: a section
origin/maindoes not hold (a release branch's own new section, a record-back's, or a ref not fetched since the record-back), and every section where the ref does not resolve (CI's depth-1 checkout of a tag, a remote of another name, the sdist). AtHEADequal toorigin/mainit compares the file with itself; what is onmainstays the tags' question, asked at the cut. The in-place allowance of #927 is unchanged, so an entry that rewrites an existing line is still not seen. RELEASING.md, "Changes land onmain", step 3: whenevermainis taken into a branch, and aftergit fetch origin,git diff --unified=0 origin/main -- CHANGELOG.mdmust start every hunk abovemain's first released heading, and the test must pass. The fetch comes first because a staleorigin/mainhides the misplacement: the same merge over anorigin/mainat bb5e6f1 is green, with[1.0.0rc13]skipped by name.RELEASING.md, "Later releases on an existing line", step 2: three of the four picks for 1.0.0rc14 (#967) applied theirCHANGELOG.mdhunks with no conflict onto a branch with no[Unreleased]heading, and before the last commit the file differed frommain's by 7 insertions and 56 deletions. The procedure now says such a pick is left as it is, because the last commit writes the file again frommain's whatever the picks left. It also says that a pick whose whole diff isCHANGELOG.mdand that conflicts is resolved tomain's file at that commit, where--ourswould leave it empty and an empty pick reads as a forward-port. The one such pick so far, #954's at rc13 (#955), had been resolved that way with no rule granting it; rc12's picks (#928) tookmain's file too, in picks that also carried code. By the owner's ruling of 2026-10-07 the pick is followed by its proof, pasted into the PR body and checked by the reviewer:git diff --stat <its sha> HEAD -- . ':!isocenter/_version.py' ':!CITATION.cff'prints nothing, or exactly the files the line's left-out list, its branch-only fixes and an earlier--oursaccount for. A picks PR still runs no tests of its own; the release commit's integration run is what tests the branch's file.- Output: none.
- A merge of
-
The policy fingerprint is pinned for input the loader never gives it, and over what the loader does give (#754, owner ruling Q4 A). A status is compared with
"v1:"plus the sha256 ofconfiguration._canonical_policy_v1, and that form is a store format from 1.0.0. Its tests pinned it over ordinary rules. Measured...