Skip to content

Isocenter 1.0.0rc15

Pre-release
Pre-release

Choose a tag to compare

@kvnlng kvnlng released this 07 Oct 20:55
· 139 commits to main since this release
33d8811

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 in annotations.json the lead of 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:1 to I). An MDC 2:3 channel whose Code Meaning says Lead III is written III where it was V1. A table-named channel whose name another signal of its record shares is written as its Code Value (II to 2:2), with one new WARNING row for that record, which grades the run REVIEW_REQUIRED where it graded PASS.
  • #963: in annotations.json, a finding loses its lead key 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.md carries <!-- tutorial: inputs=waveform_ecg.dcm -->, so tests/test_tutorials_run.py runs it whole over the 12-lead ECG bundled with pydicom, as it runs docs/tutorials/*.md. The page's directory tree, a ```text block nothing checked, is replaced by shown output: the path export() returns, the three files beside the header, and the twelve lead names read from the .hea text.

    • Why the guard left the code. The tutorial runner executes a page's fences in a namespace whose __name__ is __tutorial__. Measured on main at bb5e6f1 with the header added and the tree removed: the quick start's one fence, under if __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_failures gains one rule, with the others for code the runner would not run: a python fence holding an if __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 with ast, so a comment or a string that quotes the guard is not one, and if __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 --changed selects the runner for any .md change.
    • Output: none. Documentation and one test rule.
  • 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.py gains broken_file_links, beside the fragment check, and test_every_documentation_file_link_resolves over 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 --strict does (mkdocs 1.6.1, measured over a 17-link probe page: 9 warnings, build failed), but it runs only in docs.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 page exclude_docs keeps off the site, a directory-style dir/, and raw-HTML <a href> and <img src>. And it never reads README.md, CHANGELOG.md or RELEASING.md.
    • The rules, each named in the failure message: [absolute]; [no such file]; from a page under docs/, [leaves docs/: not on the site] and [excluded from the site] (the directories exclude_docs names, read from mkdocs.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 one convert per 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.snippets include or a tab body, the cross-references mkdocstrings generates, nav entries, the fragment of a link into a file that is not a checked page, and external URLs, which are never fetched.
    • Output: none.
  • The released-sections test also compares each section with origin/main's, and RELEASING.md says what to check after main is merged into a branch and what a pick does to CHANGELOG.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 ### Fixed entries, 37 lines, landed at the end of [1.0.0rc13]; the developer moved them back by hand. Reproduced with git merge and with git merge-file over 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.py compared each released section with the newest present tag that holds it. Over that merge it is red where v1.0.0rc13 is 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 on release/X.Y, which main does not reach, so git fetch origin main brings 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.md also never said to run the test after merging main.
    • 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 with v1.0.0rc13 absent: 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/main does 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). At HEAD equal to origin/main it compares the file with itself; what is on main stays 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 on main", step 3: whenever main is taken into a branch, and after git fetch origin, git diff --unified=0 origin/main -- CHANGELOG.md must start every hunk above main's first released heading, and the test must pass. The fetch comes first because a stale origin/main hides the misplacement: the same merge over an origin/main at 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 their CHANGELOG.md hunks with no conflict onto a branch with no [Unreleased] heading, and before the last commit the file differed from main'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 from main's whatever the picks left. It also says that a pick whose whole diff is CHANGELOG.md and that conflicts is resolved to main's file at that commit, where --ours would 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) took main'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 --ours account 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.
  • 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 of configuration._canonical_policy_v1, and that form is a store format from 1.0.0. Its tests pinned it over ordinary rules. Measured on main at bb5e6f1, three edits of the form passed every test: a rule key whose value is None dropped, a key that is not a string hashed with its type, and the tag lowercased inside the form. Each would have made a 1.x store's statuses read as another policy's, or two policies read as one. The form is unchanged; these are tests. The owner ruled that each property below stays as it is:

    • value: null is another policy than no value. A file can say it: it loads as {'action': 'REPLACE', 'value': None}, save() writes the line back, and the fingerprint is the same after a save and a reload. A file that gains or loses the line is a re-audit.
    • A set value is hashed as its text, which is in hash order, so a policy holding one has a different fingerprint in each process, and a status recorded under it reads as another policy's in the next (it fails closed, with the #555 notice). Memory only: the loader refuses a set. The test runs the form in child processes under fixed PYTHONHASHSEEDs.
    • Rule keys 1 and "1" are one key, because a key that is not a string is hashed as its repr; of two such entries in one rule the later one is kept. Memory only: the loader refuses a key that is not a string.
    • An upper-case tag assigned to phi_tags directly is a second rule and another policy. set_phi_tag("0008,103E", ...) and the loader lowercase; the form does not.
    • A list and a tuple are one value, and action is not case-folded (KEEP and keep are two policies; the loader decides which spellings load).
    • A string value's surrounding whitespace is kept, and a rule key's case is not folded (owner ruling on the review of #969). value: " v " is a line a file can hold; it writes another replacement than value: "v" and is another policy. Action beside action is memory only: the loader refuses it as an unknown key. Stripping the value, or lowercasing the key, inside the form passed every test until these.
    • Also pinned as bytes: a frozenset, bytes, a date and a rule that is None as their type and text; NaN; a nested mapping sorted; phi_tags=None as {}; remove_private_tags by truth.
    • Over the loader (tests/test_the_policy_fingerprint_is_stable_over_what_the_loader_gives.py): a file save() writes loads to the policy it was written from, over none, the floor, basic and create_config()'s scaffold; key order, rule order, a tag's case and a rule's name do not make another policy; basic and basic@2026c are one; a new Session, a file with no privacy_profile line and the scaffold are one fingerprint.
    • Not pinned, deliberately: a rule value that is a mapping with keys of two types makes the form raise TypeError (#962). An input that raises has no stored fingerprint, so it can be given one after 1.0 without moving any other.
    • Output: none. No line of isocenter/ changes.
  • The order that keeps a PHI status with its own policy is tested (#753). A status and the policy it was recorded under are two fields, read by the save thread while a scan may be recording the next status, with no lock. What stops a row from holding one status beside another status's policy is an order: record_phi_status advances _revision first and writes _phi_status_revision last, and a reader reads _phi_status_revision first and _revision last, so a read that overlaps a write sees the revisions disagree and reports UNSCANNED. The order is kept at three sites, not the one the issue named: TrackedEntity._phi_status_record, the save's restatement of it in persistence._status_columns, and the writer. Measured on main at bb5e6f1, swapping the reads in either reader, or writing the status before the revision, left 105 tests green each time.

    • tests/test_a_status_and_its_policy_are_read_as_one_pair.py captures the field writes the live record_phi_status makes, in the order it makes them, and replays them between the live reader's own field reads at every point: all 70 ways one read can overlap one write, and all 495 for two writes. No thread, so it is deterministic on both builds. On main no schedule returns a torn pair from either reader. With the reads swapped in one reader, that reader's tests fail; with the writer reordered, both readers' do, naming each schedule and the pair it tore ((IDENTIFIED, base-b), (REMEDIATED, base-a)).
    • What it does not show: that one attribute read is atomic on the free-threaded build. Nothing in the test runs on two threads.
    • Output: none. No line of isocenter/ changes.
  • test_map select's default base and its refusal are tested in scratch repositories (#800). With no --changed-base, select diffs from HEAD's merge base with origin/main, then main, and exits asking for a base when neither gives one. One test reached that code, and only in a checkout where a main exists. Measured on main at bb5e6f1, swapping the two candidates passed every test of test_changed_code_selects_its_tests.py, and so did turning the fall-through into a raise with the refusal reworded. Five test functions (six cases) now build repositories under tmp_path (first branch trunk, origin/main written by git update-ref, no developer configuration, GIT_DIR and its kin removed) and pin: origin/main before main, by two candidates whose merge bases differ; main alone; an origin/main that shares no history with HEAD passed over for main; the refusal, by its two phrases, with neither ref and with both refs and no common ancestor; and a named base that does not resolve refused, never replaced by main.

    • The script is unchanged. The issue's second item was already true of it: merge_base asks git merge-base and moves on when that fails. It was the #798 test's own guard that asked whether the ref exists, so in a clone holding an unrelated origin/main the test took the default path and failed on a correct refusal. The guard now asks git merge-base HEAD <ref>, and a test shows the two questions differ.
    • Test only. Output: none.
  • pytest --changed selects the tests that read a changed module's source, on a function edit too (#779, owner ruling Q7 A). For a function the map covers, the selection was the tests that ran it and the tests that read every *.py by glob or walk. A test that reads one named module as text runs none of the function: test_pixel_geometry.py::test_module_imports_nothing_heavy parses pixel_geometry.py for its imports, and with a fresh map an import numpy added inside pixel_geometry._contradiction failed it and did not select it; a WfdbExporter.export change did not select test_wfdb_privacy.py's test of that method's signature. Between releases both were selected by accident, through the widening for tests a stale map cannot speak for, and the gap opened each time the map was rebuilt.

    • The rule (scripts/test_map.py::source_readers): a changed isocenter/**/*.py also selects every tests/test_*.py whose text holds the module's file name (wfdb.py), or that holds a source-reading spelling (getsource, getsourcelines, ast.parse(, .__file__) and the module's name as a whole word. Whole files, found by what the file does, as the glob readers are; it only ever adds, and prints isocenter/x.py: N test files read its source by name -> added.
    • Measured at this commit, beyond the 16 files every *.py change already selects: a mean of 4.7 test files per module over the 35 modules that are not __init__.py, about 45 runner-seconds by tests/shard_timings.json; the largest is session.py with 26 files and about 225 s; four modules add none (blob_kind.py, builders.py, manifest.py, sidecar.py). An __init__.py adds 7 files (about 43 s), because __init__ is a word test files hold.
    • Not taken: rule 7's needle (the module's name anywhere in the file) for function edits. Measured in the design, it is 71 files per module on average and the whole suite for session.py, store.py, entities.py, exporters/dicom.py, persistence.py and io_handlers.py.
    • Not seen: a reader that reaches the source through a helper in another test file, or through importlib, pkgutil or inspect.getfile. The word and the spelling may sit anywhere in the file, so a file that parses one module and mentions another is a reader of both.
    • RELEASING.md step 3 states the rule for a by-hand selection. Tooling only. Output: none.
  • A release branch merged into main with its version files is caught, where history shows it (#931, owner ruling Q6 A). RELEASING.md forbids the merge because git makes it silently: main takes the release's version and its dated changelog heading, the three version files still agree, and test_the_changelog_opens_with_unreleased_or_the_declared_release passes, because the tree is exactly a release branch's. Measured in four scratch repositories (a line's first release merged into main by merge commit and by squash, with and without an entry merged to main since the cut): no conflict in any, top_heading_problem passed all four, and the released-sections test (#927) was red only for the two where main had its own entry, which it needs the tag to see. No tracked fact can tell the trees apart, since a merge carries every file main did not change as the release has it.

    • tests/test_version_contract.py::test_this_checkout_is_not_a_release_merged_forward asks git two things of a tree whose first changelog heading is its own declared version: is the tag v<version>, where the clone has it, an ancestor of HEAD; and is the commit that last changed isocenter/_version.py on HEAD's first-parent chain. A squash of a tagged release fails the first, a merge commit the second. Both hold at every tag from v0.9.6 to v1.0.0rc13 and on both release branches (measured in the design), because a PR into release/X.Y merges by squash.
    • Not asked of a tree that opens with [Unreleased]: a work branch that took main's release record by a merge commit has its version set off the first-parent chain, and is right.
    • Not caught: a squash of a release commit that has no tag yet; a merge commit whose resolution of _version.py matches neither parent; any clone with no tags and no history (a shallow checkout of a branch, where both questions are silent). A git archive copy or an sdist skips the test.
    • Red on a healthy branch if release/X.Y ever takes its release commit by a merge commit rather than the squash RELEASING.md prescribes; the message says so.
    • Test only. Output: none.
  • The CI shards' timings are refreshed from each release run, and a test says when the suite has outgrown its shards (#935, owner ruling Q3 A). #935 moved the suite to eight shards cut from runner seconds, taken once from the rc13 rehearsal's logs by a script that was never committed. The file goes stale as tests are added: a file with no number weighs the median, and 78 such files were 36% of the suite's seconds before anything noticed.

    • tests.yml: the Run Tests step passes --record-shard-timings=${{ runner.temp }}/shard-timings.json, and a new step uploads that file as the artifact shard-timings-<version>-<shard>, with if: always() (a shard with a failing test still measured its files) and continue-on-error (bookkeeping never turns a release red). A shard killed at its timeout writes no file and uploads nothing. The step is capped at 2 minutes, so the step sum is 40 against the job cap of 45, which stands. publish.yml is unchanged and inherits the step.
    • scripts/shard_timings.py merge <dir> --out <file> reads what gh run download --pattern 'shard-timings-*' wrote and gives, for each test file, the median across the versions, in the recorder's own format. It reads the shard count from tests.yml and refuses a run in which any version lacks a shard, or holds one the workflow does not have, or recorded a file in two shards, or when the folder is missing or holds no recording: a sentence each time, never a traceback, and nothing written. A file one version reported no test of takes the median of the versions that ran it.
    • RELEASING.md, "Cutting a release", step 8 gains the refresh, after the record-back and in a PR of its own (owner ruling on the review of #969): only from the publish run of the newest release line, and only when that run uploaded the timings; otherwise the file is left alone and the record-back PR says why. A release line that does not hold the upload step yet, as release/1.0 did not at rc14, has nothing to refresh from. When the refreshed file turns the capacity test red, the same PR adds the shards.
    • test_the_heaviest_shard_fits_the_step_with_room fails when the heaviest shard's recorded weight passes half the Run Tests step, half because one runner was measured 1.75 times slower than another at one commit. Today it is 555 s of 1,500 (37%), so the test turns red when the suite has grown by about a third, at a refresh, and says to add shards or raise the cap. It rests on the file holding runner seconds: a local recording is about 2.3 times lighter and would pass with the suite twice the size, and nothing checks the unit.
    • test_the_test_step_runs_for_every_matrix_entry (review of #964): if: matrix.shard != 8 on the Run Tests step left the matrix reading 1..8 and passed every workflow pin with an eighth of the suite unrun. No if: that reads the matrix, and no continue-on-error, on that step or its job. And the step's script is the one pytest command, whole: [ "${{ matrix.shard }}" = 8 ] || pytest ... skipped the same shard in the shell and passed the first version of this pin (review of #969).
    • Not proven here: the upload itself. No workflow runs on a pull request; the first run of it is the next release rehearsal, or dispatch of tests.yml, made from a ref that holds this change. tests/shard_timings.json is not retaken in this change.
    • Output: none.

Fixed

  • A WFDB record no longer names a Lead III V1, nor two signals by one lead name; and a lead's Code Value is compared without its surrounding whitespace (#832, owner rulings Q1 A, Q2 A, Q3 A). Since #828 a Channel Source coded in DICOM's ECG lead context group (CID 3001) is written to the .hea signal line, and to lead in annotations.json, as the lead's name. Three things were wrong with how, each measured on main at bb5e6f1 through ingest() and export(format="wfdb"), identically on 3.12 and 3.14t, with no WARNING, ERROR or DATA_LOSS row written (each export wrote its EXPORT row, as every export does):

    • MDC 2:3 was always V1. PS3.16 2011 prints CID 3001 "ECG Leads", version 20080927, with two rows for that code, MDC 2:3 Lead III and MDC 2:3 Lead V1; the 2013 version (20130613, current in 2026d) moved Lead III to 2:61. So a cart built between the two codes Lead III as 2:3. A 12-lead file coded that way exported I II V1 aVR aVL aVF V1 V2 … V6, and its limb leads alone (2:1 2:2 2:3) exported I II V1: Lead III named V1, with no second V1 beside it to notice. The Quick Start pipeline changes neither: basic@2026c leaves the Channel Source item as it is.
    • Two signals could share one name. Besides the file above, Lead II coded on two channels exported II II, and a local code spelled V1 beside a coded V1 exported V1 V1. wfdb.rdrecord(channel_names=["V1"]) over such a record returns one of the two signals and says nothing, and the issue reports that the PhysioNet Challenge's reorder_signal asserts that names are unique (not run here).
    • A Code Value with a space in front was not named. Code Value is SH, "a character string that may be padded with leading and/or trailing spaces" (PS3.5 §6.2); pydicom strips the trailing space on read and not the leading one, so ' 2:1' reached the table unstripped and was exported as 2:1, a bare code where I was meant.
    • Now, the Code Value is compared stripped (Q3 A): ' 2:1' is named I. Stripped is str.strip(): any whitespace in front or behind (a tab, a no-break space, a line break), not the space character alone (owner ruling on the review's Q2). Only the comparison changes; a code the table does not name is written as before. An interior space is still significant (2: 1 is not 2:1).
    • Now, an MDC 2:3 channel is named by its own Code Meaning, and this code only (Q1 A): III when the Channel Source item's Code Meaning (0008,0104) says Lead III, and V1 otherwise. "Says Lead III" is the comparison a Channel Label already gets: case ignored, whitespace collapsed, an optional Lead in front, then exactly iii; so Lead III, lead iii and III are, and Derived Lead III, Lead III (Einthoven) and Lead IIII are not. Nothing from the Code Meaning is written: the name is one of two fixed strings. The 2008 12-lead file now exports I II III aVR aVL aVF V1 V2 … V6 and its limb leads I II III; a file coded to the 2013 table is unchanged.
      • The cost, and why it was taken. PS3.3 §8.3 says the Code Meaning "shall never be used as a key, index or decision value". This reads it as one, in the one place where the standard's own table made the code ambiguous, because the alternative is to go on calling a Lead III V1. No other key reads its meaning: SCPECG 5.6.3-9-3 and MDC 2:4 with a Code Meaning of Lead III are still V1 and V2.
      • The limit. A Lead III coded 2:3 whose Code Meaning is empty, or says it in another way (Ableitung III), is still named V1, with nothing said, when no other signal of the record is named V1.
    • Now, when two or more signals of one record would carry the same name, each one the lead table named is written as its Code Value instead (Q2 A), in the .hea and in annotations.json alike. Names are compared ignoring case, since the consumer the issue names matches leads that way. Lead II coded twice is written 2:2 2:2: the file says one code twice, and the header then says exactly that. A 2008-coded record with empty Code Meanings (2:1 2:2 2:3 2:3 2:4) is written I II 2:3 2:3 V2. Only a name the table produced falls back: a Code Value written as it stands, a kept Channel Label and a ch<N> token have no other spelling, so a local code V1 beside a coded V1 becomes V1 and 5.6.3-9-3, and one pressure code on two channels (G-DB22 G-DB22) is unchanged and draws no row, because nothing was named.
      • The row. One WARNING audit row per record, on the instance's SOP Instance UID, and the same sentence in the log: WFDB record: channels 1 and 2 both resolve to the lead name II, so each is written as its Channel Source Code Value. A name shared by two signals cannot say which lead either is. Channel numbers count from 1, as DICOM numbers them. It holds channel numbers and the table's lead name and no Code Value and no Code Meaning, since a non-conformant source can put anything in either. Three or more channels read channels 1, 2 and 3 all resolve to …; a single coded channel whose name another signal carries without a lead code reads channel 2 resolves to the lead name V1, which another signal of the record carries without a lead code, so it is written as …; several names in one record are listed in the one row; all but the two-channel form end A name shared by more than one signal cannot say which lead each of them is. The two-channel closing sentence is used only when those two are all that carried the name: two coded V1 beside a local code v1 read channels 1 and 2 both resolve to the lead name V1, so each is written as its Channel Source Code Value. A name shared by more than one signal cannot say which lead each of them is. The wording beyond the two-channel sentence is this change's, not the specification's, and row wording is not frozen.
      • The grade. A WARNING row grades the run REVIEW_REQUIRED (docs/analytics.md, section 4). An export of such a record that graded PASS now grades REVIEW_REQUIRED: a person should look at a record whose leads the export could not name. A record with nothing to fall back from draws no row and grades as before.
      • A column past the channel definitions. format_header gives a sample column past the defined channels the last definition, so one MDC 2:8 definition under three columns was written V6 V6 V6. Those are three signals with one table name: they are now 2:8 2:8 2:8, with the row naming channels 1, 2 and 3.
    • How. The rule is about two channels, and format_header and murmur._lead_for each saw one. exporters.wfdb._signal_descriptions(waveform, n_signals) resolves the whole record; WfdbExporter._write_instance calls it once, over the sample columns, and hands the same list to both writers, so the two files cannot disagree about a lead's name. format_header and murmur.build_annotations take descriptions=None and compute their own when called directly. WaveformChannel gains source_meaning, its last field. None of these names is in docs/api/stability.md.
    • CONFIG_VERSION is unchanged: a lead name is computed by the exporter from the Channel Source item, and no rule's findings, written values or reach move (as #828).
    • docs/waveforms.md "Lead names" says all three, and its claim that leads are "named as PhysioNet records name it" is replaced: PTB spells its orthogonal leads vx vy vz, not X Y Z. tests/test_waveform_model.py pins the padded codes with their interior-space controls, each spelling that is and is not Lead III, and that no other key reads its meaning. tests/test_two_signals_of_one_record_do_not_share_a_lead_name.py pins fifteen channel lists against literal descriptions and clashes, the three files through a session read from the .hea text and by wfdb.rdheader, annotations.json agreeing with the header where the fallback fires (one definition under three columns included), each row whole, the grade from the report file, and the records that draw no row.
    • Output: in a WFDB .hea, the description field of a signal line, and in annotations.json the lead of a finding on that channel, change for three kinds of channel and no other: a lead code with whitespace in front of it is written as the lead's name where it was the bare code (2:1 to I); an MDC 2:3 channel whose Code Meaning says Lead III is written III where it was V1; and a table-named channel whose name another signal of its record shares is written as its Code Value where it was the name (II to 2:2), with one new WARNING audit row for that record, which grades it REVIEW_REQUIRED. Nothing else on the signal line, the record line, the .dat file or the start-date comment moves. No golden-cohort member reached any of it (the two .hea records in the fingerprint hold SCP-ECG codes and reference IDs, with no MDC 2:3, no padded code and no shared name), so a new synthetic member, synthetic:ecg_lead_codes (scripts/golden_cohort.py), is added, four of whose five files are this entry's (the fifth, -short, is #963's, below): -2008-12lead (the 2008 table's twelve leads with marks on channels 3 and 7, recorded I II III aVR aVL aVF V1 V2 V3 V4 V5 V6 with lead III and V1), -2008-limb (I II III), -padded (' 2:1', '2:2 ', ' 2:61 ', 2:62; recorded I II III aVR) and -coded-twice (Lead II on two channels with a mark on channel 2; recorded 2:2 2:2, lead 2:2, and the WARNING row). In fingerprint/output.json, retaken on 3.12 and checked clean on 3.14t, the member is the one new in section 2, Cohort (1 difference in 1 group); no existing member's output changes (sections 3 to 6: 0 differences), the two existing .hea records included.
  • A mark on a channel the WFDB record has no signal for carries no lead (#963, owner ruling). A waveform group may hold more Channel Definitions than NumberOfWaveformChannels (003A,0005) says it has sample columns. The .hea gets one signal line per sample column, so the definitions past the last column describe no signal of the record. A Waveform Annotation whose Referenced Waveform Channels (0040,A0B0) named one of them was still written to <record>.annotations.json with that definition's lead name. Measured on main at bb5e6f1, three definitions (MDC 2:1, 2:2, 2:61) over two columns with a mark on each channel: the .hea wrote two signal lines, I and II, and the three findings carried lead I, II and III. A reader that places a mark by its lead had no III to place it on.

    • Now that finding has no lead key. The mark itself is kept, with its position, category and label: its sample positions are on the record's own axis. The key is absent, not null, which is how the bridge already writes every mark with no lead (a channel number of 0, for one); Murmur's schema does not require lead, and build_annotations has never written a null one.
    • What does not change: the .hea (it never had a line for such a channel), a mark on a channel that has a signal line, and a mark naming a sample column past the definitions, which carried no lead before and carries none now. No audit row is written for it and the grade does not move: none was ruled.
    • Only through export(format="wfdb"). murmur.build_annotations called directly with no descriptions= does not know the sample columns, so it gives every defined channel a lead; the exporter hands it the list the header was written from. (That direct call is not unchanged by this release: since #832, above, it applies the shared-name rule over the defined channels, so definitions I II II give 2:2 on the second and third where they gave II.)
    • tests/test_two_signals_of_one_record_do_not_share_a_lead_name.py held this file as a pin of the old ['I', 'II', 'III'], written in #832 while the question was open; it now asserts the two signal lines, the three findings by position, the third with no lead key, and no WARNING, ERROR or DATA_LOSS row. docs/waveforms.md says it beside the rule for marks on a discarded group.
    • Output: in <record>.annotations.json, a finding loses its lead key when its mark names a single channel of the kept group whose number is greater than NumberOfWaveformChannels (a file whose Channel Definitions outnumber its sample columns). Nothing else in that file, and nothing in the .hea or .dat, moves; no audit row is added or removed. No golden-cohort member held such a file, so synthetic:ecg_lead_codes gets a fifth file, -short (three definitions over two columns, marks on channels 2 and 3; recorded I II, with lead II on the first finding and none on the second). It is part of the one new member in section 2, Cohort.
  • Corrections to the 1.0.0rc14 record: ten **Output:** lines said less than the release does (#764, #679, #926, #775, #721, #733, #722, #747, #703, #937). An **Output:** line is where a change to what an export writes, what rows it records or what a run grades is named. Each of the eleven lines that C2 (#947) and C3 (#958) shipped was read against a measurement of v1.0.0rc13 and of the tree 1.0.0rc14 was cut from (main at 28057af2), on 3.12.14 and again on 3.14.7t, which agree: every exported element read from the written file, in the Implicit VR export and in the JPEG 2000 one, which names its VRs on the wire, and every grade read from the report file. No behaviour changes here: this states what 1.0.0rc14 already does. Found by the review of the rc14 release commit (PR #968) and the audit it asked for. The rc14 section's lead names some of this for #764, #926, #775, #747, #703 and #937; where it and a line below disagree, the line below is the measured one. #593's line ("none") was audited too and is accurate: the refusal's message changes and the export does not.

    • Nothing below puts an identifier into a file graded PASS or stamped (0012,0062) YES. Where a grade or a marker moves on a file that was already being written, it moves to REVIEW_REQUIRED and to no marker, with one exception: #926's private tag, dropped under a graded row before and written with none now, which is the caller's own number. Elsewhere an export that raised now writes (#926, #722, #679), an export that returned now raises and writes nothing (#775, #721), or a file leaves the export (#747).
    • The 1.0.0rc14 **Output:** line for #764 said "none. No exported byte changes". The owner's value in the file is unchanged, as it says. The de-identification markers are not: they go only on an instance whose own status is REMEDIATED or CLEARED, and #764 leaves the instance IDENTIFIED in two sequences. They withhold different markers, which the rc14 lead did not separate.
      • An owner REPLACE beside a hand-built instance REMOVE on Patient's Name, under the floor: three markers. Before: instance REMEDIATED, the file carried ANONYMIZED with (0012,0062) YES, (0012,0063) and (0028,0303) MODIFIED, no decline row, PASS. In 1.0.0rc14: instance IDENTIFIED, the file carries ANONYMIZED and none of the three, one REMEDIATION_DECLINED row, REVIEW_REQUIRED (the declined remediation, and one instance read IDENTIFIED).
      • anonymize(<instance findings>) then anonymize(<owner findings>) under privacy_profile: none plus 0010,0010: REMOVE or 0008,0020: REMOVE: two markers. Before: the element written empty, (0012,0062) YES and (0012,0063), PASS. In 1.0.0rc14: the element written empty, neither marker, no row, REVIEW_REQUIRED (one instance read IDENTIFIED) until anonymize(audit()), after which both are written and the run grades PASS. (0028,0303) is in neither file, before or after, and not in the whole-report control either. Under the name rule Study Date is shifted in the file (one REMEDIATION_SHIFT_DATE row) and under the date rule it is written empty; in both, Series Date, Acquisition Date, Content Date and Instance Creation Date are the source's, and (0028,0303) is written only when no date in the file is as found.
      • What to do: hand anonymize() the whole report, or the owners' findings before the instances'; after instances-then-owners, run anonymize(audit()) before exporting.
      • test_the_replace_and_remove_pairing_exports_no_marker and test_the_owners_removal_in_a_later_pass_exports_no_marker_until_a_reaudit (tests/test_an_owner_stamped_copy_follows_its_owner.py) read the three tags from both exports and the grade from the report file, each beside its control; both are red with the REMOVE allowed to run on a copy its owner stamps.
    • The 1.0.0rc14 **Output:** line for #679 said "none. No exported byte changes; a hand-built finding's audit row changes as above". The markers and the grade change too. Measured after a full anonymize(audit()) under the floor, then a hand-built REMOVE on Series.modality (Study.date_shifted and Patient._phi_status measure the same). Before: the arm ran, a REMEDIATION_REMOVE row, the file carried Modality CT with all three markers, PASS. In 1.0.0rc14: the finding declines, the decline holds the entity it named at IDENTIFIED (a Series' instance, the Study or the Patient), and the file carries Modality CT and none of (0012,0062), (0012,0063), (0028,0303); the run grades REVIEW_REQUIRED, for the declined remediation and the entity read IDENTIFIED. For Patient.studies, Study.series and Series.instances the export raised TypeError before and now writes the file, with no marker and the same grade.
      • What to do: do not hand anonymize() a REMOVE naming a Python attribute the export does not write. A run that did needs anonymize(audit()) before its files are stamped again: measured for Series.modality, the three markers are then written, and the run still grades REVIEW_REQUIRED, on the decline row, which stays in the log.
      • test_a_declined_stray_remove_after_a_full_pass_exports_no_marker (tests/test_declined_remediation_is_recorded.py) reads Modality and the three tags from both exports and the grade from the report file, beside the full pass alone as its control; red with the arm writing any name the entity carries.
    • The 1.0.0rc14 **Output:** line for #926 said "none", and gave only the reason the fingerprint does not move. The body's "14 of 56 measured cells" did not say by which route a numpy value reached the exporter. By set_attr and then export(), the route a caller takes:
      • On a standard tag (US or SS, DS, IS, LO): before, export() raised json's TypeError at its leading save and wrote nothing. In 1.0.0rc14 it writes the file with the Python number: np.int64(7) as SS under (0028,0106), as 7.0 in a DS and 7 in an IS; np.float32(0.1) in a DS as 0.10000000149012 with #723's rounding WARNING (REVIEW_REQUIRED), in an IS as 0 with its WARNING (REVIEW_REQUIRED); any of them in an LO fails that file with an ERROR row, as a Python number does.
      • On a private tag: before, the save returned (the private tier stored the value's text), export() wrote the file without the tag under one PRIVATE DATA_LOSS row, and the run graded REVIEW_REQUIRED. In 1.0.0rc14 the tag is written LO (7; 1\2 for a list; 0.10000000149011612; True for np.bool_(True)) with no row, and the run grades PASS. That includes a file stamped (0012,0062) YES: with remove_private_tags: false and np.int64(123456789) set before anonymize(audit()), the file was exported without the tag, stamped YES, under the PRIVATE DATA_LOSS row, REVIEW_REQUIRED; in 1.0.0rc14 it carries LO 123456789, is stamped YES and the run grades PASS. It is the caller's own value under a policy that keeps private tags, exported as a Python int set the same way always was.
      • Written around the entity and never saved (inst.attributes[tag] = np.int64(7), no mark_modified()): the entry says the exporter's own reading "is unchanged". Measured, it is unchanged under DS, IS, LO and a private tag, and changed under a US or SS element, where a numpy float and np.bool_ are still dropped as they were: np.int64(7), np.uint16(7) and a two-valued list of np.int64 were dropped with a STANDARD DATA_LOSS row and are now written SS with no row, PASS before and after, under each of the seven tried ((0028,0106), (0028,0107), (0028,0108), (0028,0109), (0028,0120), (0028,0121), (0028,1101)). A three-valued list of np.int64 is dropped under all seven, before and now. The cause is #703, not #926: pydicom's US or SS resolver raises TypeError over a numpy integer (object of type 'numpy.int64' has no len()), and _resolve_one_ambiguous_vr now takes a TypeError from the resolver as "the decider cannot decide" where it dropped the element. Narrowing that clause back to AttributeError drops the element again with the old row (run for np.int64(7) under (0028,0106)). #703's entry says the clause is reached only for LUT Data; this is a second way in. It is stated here as measured and deliberately not pinned by a test: whether the clause is kept or narrowed is #972.
      • Written around the entity and then mark_modified() (inst.attributes["0028,1050"] = np.float32(0.1)), which neither the entry nor its line describes: before, export() raised json's TypeError at its leading save. In 1.0.0rc14 the save stores the Python float and the graph keeps the numpy object, so the live export writes the DS as 0.10000000149011612, 19 characters, with no row, and the run grades PASS, while a reopened session's export of the same store writes 0.10000000149012 with the rounding WARNING and grades REVIEW_REQUIRED. A live export and a reopened one therefore differ for a value written this way; through set_attr they do not. Stated as measured, not pinned, and not changed here.
      • What to do: nothing, unless a caller relied on a private numpy value being dropped at export; that caller should not set it.
      • test_what_export_writes_records_and_grades_for_a_numpy_number (tests/test_a_numpy_number_set_into_an_attribute_is_saved.py) pins the element, the rows and the grade read from the report file for a private np.int64, a private list and a DS np.float32.
    • The 1.0.0rc14 **Output:** line for #775 said "none. No exported byte and no audit row changes". With inst.set_attr("0011,1001", {1, 2}) (a date and an object() measure the same): before, export() returned, wrote the file without the tag under one PRIVATE DATA_LOSS row and graded REVIEW_REQUIRED; the store held '{1, 2}', and a reopened session's export wrote that text (LO under Explicit VR) with no row of its own. In 1.0.0rc14 export() raises the named TypeError before any file is written and writes no row, and the reopened store holds no such tag, so its export writes the file without it. The entry's Breaking bullet says the first half; the **Output:** line said neither.
      • What to do: set a value the private tier holds (a str, a number, a list of those) and export again.
      • test_an_export_over_such_a_private_value_writes_no_file_and_no_row (tests/test_a_value_the_store_cannot_hold_is_refused_by_name.py) asserts the refusal whole, that no folder exists, that no row was written, and what the reopened export's file holds.
    • The 1.0.0rc14 **Output:** line for #721 said "none. No exported byte and no audit row changes". True of sop_instance_uid = None, which raised before and raises now with nothing written. Not of a UID of another type. With sop_instance_uid = 12345 on one of two instances: before, export() returned, wrote the other instance's file and one ERROR row (Export failed for instance 12345: TypeError: A UID must be created from a string), and the run graded REVIEW_REQUIRED; the leading save had already dropped that instance's row from the store. In 1.0.0rc14 export() raises the ValueError before anything is written: no file for either instance, no row, and the store keeps both.
      • What to do: give the instance a str UID, or remove it from its series, and export again.
      • test_an_export_over_a_uid_of_another_type_writes_no_file_and_no_row (tests/test_an_instance_with_no_uid_is_refused_before_the_save.py) asserts the refusal, no folder and no row from both export arms, and that the same call writes both files once the UID is a str.
    • The 1.0.0rc14 **Output:** line for #733 said "none". After recover_patient_identity(restore=True) over two instances whose token holds a multi-valued tag (Other Patient Names set as a list), with one instance's list then edited in place and mark_modified() called: before, the live export wrote the edit into both files, and a reopened session's export into the edited instance's alone. In 1.0.0rc14 both exports write it into the edited instance's file alone. The values are a restored identity's, so no de-identified output is involved.
      • test_an_edit_in_place_after_a_restore_is_exported_for_one_instance (tests/test_recovery_returns_the_identity.py) reads (0010,1001) raw from each file of both live exports and of the reopened one.
    • The 1.0.0rc14 **Output:** line for #722 said "none", then named a row. Two changes it did not state as changes. After os.chdir, export() from a store opened by a relative path raised sqlite3.OperationalError: no such table: patients and wrote nothing; in 1.0.0rc14 it writes the files, and its EXPORT rows go to the original store. And the entity_uid cell of the four WARNING notices about a store from before 1.0 is the absolute path where it was the relative one (measured for the ID-less-group notice: legacy.db before, the absolute path now). No fresh store writes any of the four, as the line says. Both are pinned by the entry's own tests (tests/test_a_store_keeps_its_place_when_the_process_moves.py, and test_the_notice_names_the_store_by_its_absolute_path).
    • The 1.0.0rc14 **Output:** line for #747 named the row and the refusal, and not the grade or the file; and "exported with the UID written as a Python list's text", which the rc14 lead repeats, is true only where the UID was not replaced. A file whose Study or Series Instance UID holds two values, under the floor with anonymize(audit()): before, it was ingested, its UID was replaced like any other (the replacement was derived from the list's text, so the file carried a 2.25. UID and not that text), and it was exported with all three markers, PASS. The list's text reached a file only where the UID was not replaced, in an export with no pass or under a policy that keeps it: ['1.2.3', '1.2.4'], PASS. In 1.0.0rc14 the file is not ingested, so it is not in the export at all, and the run grades REVIEW_REQUIRED on the ERROR row; a file beside it is exported and stamped as before. For a two-valued Patient ID or SOP Instance UID the run was REVIEW_REQUIRED before and is now, as the line says.
      • What to do: correct the source file's UID to one value and ingest it again.
      • test_a_run_over_a_refused_file_exports_only_the_file_beside_it_and_does_not_pass (tests/test_a_multi_valued_linkage_key_is_refused_by_name.py) counts the exported files in both arms, reads the stamp and both UIDs from the file, and reads the grade from the report file, beside a control with no such file.
    • The 1.0.0rc14 **Output:** line for #703 named the row, and not the grade. Measured under the floor: the Explicit VR file, exported without LUT Data under a STANDARD DATA_LOSS row, graded PASS; in 1.0.0rc14 it exports LUT Data as OW and grades REVIEW_REQUIRED on the WARNING row, one per export. The Implicit VR file was refused (ERROR row, no file, REVIEW_REQUIRED) and is now exported with the same WARNING row and the same grade. Both files carry (0012,0062) YES. The grade is pinned by the entry's own test_the_explicit_twins_run_grades_on_the_warning. The same except clause also reaches a numpy integer written around the entity under a US or SS element, as the #926 bullet above says (#972).
      • What to do: read the WARNING row. The bytes are the source's; it is the file's LUT Descriptor that needs correcting, at the source.
    • The 1.0.0rc14 **Output:** line for #937 named the private PN row, and not the grade or the Implicit VR export. A private PN of two values from an Explicit VR source, kept with remove_private_tags: false: before, both exports carried the bracket text (PN under Explicit VR), no row, PASS. In 1.0.0rc14 the Explicit VR export writes it UT, the Implicit VR export carries the source's text where it carried the bracket text, each export writes the re-VR WARNING row, and the run grades REVIEW_REQUIRED. The other cases are as the line says and grade PASS as before. Pinned by the entry's own test_a_private_person_name_of_two_values_is_written_ut_with_its_row.
    • Output: nothing changes in this release; this corrects ten 1.0.0rc14 lines, as itemised above. fingerprint/output.json is not retaken: no code that writes output changed, and no golden-cohort member hands anonymize() a partial or hand-built list, sets a value by hand, restores an identity or changes directory.