feat(iptc): typed IPTC Extension structures and the complete IIM 4.2 tag table - #540
Open
justin13888 wants to merge 28 commits into
Open
justin13888 wants to merge 28 commits into
justin13888 wants to merge 28 commits into
Conversation
The tag table covered only the structural record-1 datasets and the record-2 datasets IPTC Photo Metadata maps to XMP — 26 of the 71 datasets exiv2 documents. Transcribe the complete Envelope and Application dataset definitions of IPTC-IIM 4.2 chapters 5 and 6 from references/iptc/iim-4.2.pdf: 14 record-1 and 56 record-2 entries with their names, repeatability and value-field octet maxima. The table is descriptive only — no FIELD_MAP row references a newly named dataset — so reading, merging and writing are unchanged and every unmodeled dataset still round-trips byte-exact. 2:202 ObjectData Preview Data and every dataset of records 7-9 stay unmodeled: IIM 4.2 states no determinate octet maximum for them (256000 octets for 2:202, "a binary number" of unstated width for the rest), and IimTagInfo::max_octets can only state one. Refs #422
The structured Iptc4xmpCore:CreatorContactInfo and the IPTC Extension structures had no typed model and reached callers only as raw gamut-xmp values. Add `extension`, a typed projection over that graph in the shape gamut-exif's GpsInfo already uses - from_xmp/to_xmp per structure plus one accessor pair on PhotoMetadata: - CreatorContactInfo (Iptc4xmpCore:CreatorContactInfo) - ImageRegion (Iptc4xmpExt:ImageRegion), with RegionBoundary, RegionBoundaryPoint and Entity - ArtworkOrObject (Iptc4xmpExt:ArtworkOrObject) - Licensor (plus:Licensor) Field identities, container kinds and language-alternative shapes come from the IPTC Photo Metadata Standard 2025.1 and its machine-readable technical reference. An ImageRegion keeps every property it does not model verbatim, because the standard lets a region carry any other metadata property. Every one of these properties is XMP-only - none carries an IIMid, so none is in FIELD_MAP - and therefore can never be an IIM/XMP conflict; that is documented on the types rather than given a new policy knob, and ConflictPolicy is unchanged. The PLUS namespace joins IPTC_NAMESPACES, since the IPTC Extension defines plus:Licensor and its siblings there and PhotoMetadata::from_xmp would otherwise drop them; xmp: deliberately does not, being general-purpose. Refs #422
…eference tests/techreference.rs already re-derived the IIM<->XMP mapping and the octet limits from references/iptc/iptc-pmd-techreference_2025.1.json. Extend it to the typed structures: a fully-populated instance of each is converted with to_xmp and its emitted field identities compared against the XMPid set the JSON's ipmd_struct entry defines, so an invented, missed or mis-prefixed field fails loudly rather than drifting. The four top-level properties are pinned to the ipmd_top XMPid and structure type the reference names, and their absence of an IIMid - what makes a structured property unable to conflict with the legacy carrier - is pinned there too rather than only documented. tests/oracle.rs gains an exiv2 differential over a stream spanning the Envelope record and the Application datasets outside the XMP-mapped subset; the existing fixture is record 2 only, so this is the first record-1 leg. Refs #422
STATUS.md and README.md still said no IPTC Extension structure had a typed model and that the tag table covered only the structural and PMD-mapped datasets. Replace both with what the crate now does, and state the two deferrals that remain with the issues tracking them: the eleven unmodelled Extension structures (#538) and the IIM datasets with no spec-stated octet maximum (#539). Also correct the records 3-9 deferral: IIM 4.2 defines no datasets for records 3-6 at all, so there is nothing there to name. Refs #422
Entity::from_xmp was asserted only for the non-structure case, which a mutation replacing its body with None satisfies - the only mutant the incremental gate missed. Every path that reaches an Entity through an ImageRegion goes via from_fields, so nothing else exercised the public value-level conversion with a structure. Add the round-trip. Refs #422
The tag table's stated criterion is "every dataset IIM 4.2 gives a determinate octet maximum", but 7:10 Size Mode was excluded with the records 7-9 datasets whose value is "a binary number" of unstated width. IIM 4.2 Ch. 11 defines it as "Mandatory, not repeatable, one octet" — a determinate maximum, and trivially within `max_octets`'s `u16` — so the criterion did not hold and the four places that stated it disagreed with each other on how many datasets it excluded. Name it, and restate the criterion the same way everywhere: a dataset is named when the spec states a maximum that is determinate and fits a `u16`. Exactly six are not — 2:202 (256000 octets) and 7:20, 7:90, 7:95, 8:10, 9:10 — and the boundary case is now pinned by the test rather than only described.
`iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a hand transcription with nothing but ordering and uniqueness to catch a slipped digit: changing one maximum from 128 to 7 left the whole crate suite passing, because no test consults the table for a dataset outside the ~20 the PMD reference maps. exiv2 — already this crate's differential oracle — carries an independent transcription of the same two chapters. Parse it out of the vendored sources and compare every row's octet maximum, repeatability and value kind. All 70 rows agree today; the one deliberate divergence (1:90 Coded Character Set is ISO 2022 escape sequences, not the string exiv2 calls it) is pinned as an exception rather than merely documented, and the completeness leg names 2:202 as the only row exiv2 documents that a `u16` maximum cannot express.
`ArtworkOrObject`, `Licensor` and `CreatorContactInfo` had no retention list, so their setters replaced the whole graph property: a structure carrying a vendor extension came back from a read-modify-write with that property gone, silently. Only `ImageRegion` kept what it could not name, because the standard sanctions extras there explicitly — but a real-world file carries them anywhere. Give all three the same `other` list `ImageRegion` has, filled by `from_xmp` and re-emitted verbatim by `to_xmp`. The types are `#[non_exhaustive]`, so the field is additive. Emitting the retained set now skips any field whose name the model already owns. The list is public, so a caller could put a modelled name in it and produce a structure with two fields of one name — ill-formed, and non-idempotent, since reading it back drops the second. The modelled value stays the authority.
Files in the wild write a single structure where the standard puts a Bag of them — one `plus:Licensor`, one `Iptc4xmpExt:rCtype` — and gamut read those as nothing at all, because the accessor asked the graph for an array and took `None` for empty. Read it as the sequence's single element, at both levels, through one helper. Writing is unchanged and still emits the Bag, so a read-modify-write normalises the shape rather than propagating it. That is the crate's posture elsewhere: strict on write, honest and lenient on read.
Two emitters could put something into the graph that is not a value of the property's type, both reachable from safe public API. A non-finite coordinate was written as the text `NaN`, `inf` or `-inf`, none of which is an XMP `Real`; nothing reads it back as a number. The emitter is infallible by design and matching its sibling's shape matters more than a newtype here, so the value is skipped and the omission is documented. Whether emission should become fallible instead is filed separately. And a `CreatorContactInfo` with nothing in it wrote an empty structure, which a reader reports as "present but blank", where the three array setters remove the property. All four now agree: nothing to say removes the property.
The four structured-property setters judged emptiness differently: an empty block removed the property, but an empty *member* of a bag was written as a blank element that a reader reports as present-but-blank. The array setters now skip a member with no field at all, and remove the property when nothing is left, matching the setter that already did.
A field whose name a structure models but whose value the typed reader rejects was neither parsed nor retained: it read as absent, and the excluded-because- modelled rule then dropped it on write, so an identifier carrying a structure and a coordinate carrying non-numeric text both vanished from the graph. Retention is now decided by consumption rather than by a list of modelled names. A field the read took no value from stays in the type's `other` list whether the model names it or not, which also gives the three nested structures - RegionBoundary, RegionBoundaryPoint and Entity - the retention the four top-level ones already had, so a vendor field inside a boundary survives as one beside it does. On write, a retained field is dropped as a namesake only when the modelled field was actually emitted; when it was not, it is the only copy of that name and is written.
The helper took the first entry of an alternative list and re-emitted it labelled `x-default`, so a list a writer had ordered with a French entry first came back with the French text standing in for the default and the real default gone. The entry is now found by its `xml:lang` qualifier, compared case-insensitively, as XMP Part 1 8.2.2.4 requires and as `XmpMeta::get_lang_alt` already does for a top-level property. A list with no default entry at all is not read: it reads as absent and, having consumed nothing, is kept verbatim, so the languages it does carry survive instead of one of them being relabelled.
The drift guard compared octet maxima, repeatability and value kind but not
names, because the C++ parse threw string literals away before splitting a row
into fields - so a mistyped dataset name survived the whole suite. Literals are
now indexed rather than discarded, which keeps a description's commas out of
the field split while leaving the title readable.
exiv2 titles 28 of the 70 datasets more briefly than IIM 4.2 names them
("Caption" for "Caption/Abstract"). Those rows pin both spellings, in the shape
the octet-limit exceptions already use, so every name in the table is pinned to
something: 42 to exiv2's title directly, 28 to a self-invalidating pair, and
7:10 Size Mode - which exiv2 does not carry - to the spec's own spelling.
The drift guard now compares dataset names as well as octet maxima, repeatability and value kind, and retention covers a modelled field whose value the typed read cannot take, not just a field the model does not name.
A typed read consumed a field whenever it could parse a value out of it, which is a claim about the read, not about the round trip. Three shapes parsed cleanly and were then written back as something else, so the field was neither modelled nor retained: a URL held as `rdf:resource` came back as element text, a value's qualifiers were dropped, and a coordinate stated as `NaN`, an infinity or `1e400` vanished entirely - `put_number` refuses to write a value the XMP `Real` type has no form for, and by then the field had already been marked consumed. Consume a field only when the property the writer will emit for the value read reproduces the field that was read - value, container kind, qualifiers and all. Every `parse_*` now sits beside the `*_value` that writes its result, and the reader compares against that very value, so the two cannot drift apart. Two lexical re-spellings still count as reproducing a field, because both are idempotent and value-preserving: a number's form, and the case of an `x-default` language tag. The rule subsumes the read-side special cases and closes the shapes nobody had named: an `rdf:Alt` or `rdf:Seq` handed to a list field, an array item of a kind the model skips, a bare structure where an array belongs, and two fields of one name are all retained now instead of being normalised away. `put_other` no longer drops a retained field because an earlier *retained* field shares its name - only a modelled one it was actually emitted beside can displace it. The one-field shape corpus states the law over every shape at once, and the enumeration of what retention newly covers is derived from it rather than listed by hand.
The array setters dropped a member that carried no field at all, which shifted every later member's index, so `image_regions()` followed by `set_image_regions()` was not the identity; `put_nested_array`, writing the same kind of array one level down inside `rbVertices`, kept it. Idempotence is the contract the module states, so the two writers now agree on keeping it: nothing to say is a member's business, not the writer's, and only an empty slice removes the property. They also rewrote the property as an `rdf:Bag` whatever it had been, so a caller who wrote an `rdf:Seq` lost the one thing a Seq states that a Bag does not. The setter has the existing property in front of it, so it keeps that kind and falls back to the standard's Bag for anything that is not already an array.
`iim-4.2.pdf` was called not machine-readable, and the tag table's names were pinned to exiv2's titles on that basis. exiv2 abbreviates 28 of the 70 datasets it shares with gamut, so those rows had to pin both spellings from a hand-kept table - which pins each name to a copy of itself and lets a name mistyped the same way in both places through. The premise is false. The standard sets every DataSet's name in a column of its own, and `pdftotext -bbox-layout` recovers it by position: all 77 rows of chapters 5, 6 and 11-13, including the names set over two or three lines and across a page break. All 71 of gamut's rows agree with it today. `pdftotext` is a system package the toolchain does not provision, so the extraction runs out of band: `tests/data/extract-iim-names.py` writes `tests/data/iim-4.2-dataset-names.tsv`, the artefact is committed, and the test reads it back. Renaming 2:92 to "Sublocation" now fails; before, only a reviewer reading the PDF would have caught it. exiv2 keeps the columns the standard states in prose - octet maximum, repeatability and value kind - and loses the name comparison it could never make well.
The crate docs described retention as covering "every field its typed read took no value from", which is the read-side rule the extension module no longer uses. Say what decides it: whether the property the writer will emit reproduces the field that was read, and what the two remaining differences are.
The retention rule reads whether the writer would give the field back, so the relation that decides it needs a test that fails for that one reason. Replacing the numeric comparison with a blanket "any two texts match" left every round trip in the module passing, because the writer never emits a different text for a field it did not re-spell.
The module lists what retention covers; the derived enumeration in the tests covers thirteen shapes and the prose covered eleven. Plain text written where a language alternative belongs, and an rdf:Bag written where the model writes an rdf:Seq, were both missing.
The relation deciding retention was pinned only by its numeric clause, so four of its parts were free to change without a test noticing: a URI matched any URI, a structure matched any structure of the same field count, a qualifier list matched any list of the same length, and every qualifier was treated as a language tag and matched case-insensitively. Each of those turns a field the writer cannot reproduce into a field marked consumed, which is the destruction the rule exists to stop. One test per clause, each failing for its own reason: the URI target, the structure's fields as an unordered set, the qualifier list matched pairwise, and the language tag as the one qualifier XMP Part 1 §8.2.2.4 matches without regard to case - neither the `xml:` namespace alone nor the name `lang` alone makes one.
The reproduction rule stopped at the structure boundary. Inside a structure a field is consumed only when the property the writer emits for the value read gives that field back; the four accessor pairs on `PhotoMetadata` applied no such gate, and the setter is the other half of the pair a caller reads through. Executed on a serialized packet, read then set-back silently dropped what a file's producer had put there: an array member that is not a structure, a qualifier on the property, a qualifier on an `rdf:li`, an `rdf:Alt` rewritten to `rdf:Bag`, a bare structure rewritten to a Bag, an array or structure holding nothing at all, and a qualifier on the contact-info property. Only `rdf:Seq` survived, and only because a test happened to pin it. The gate moves up: one `Projection` per property reads a value only when what the setter will emit reproduces the property that was read, and the setter removes a property only when the read reported one — the graph itself is the retention list a structure keeps in its `other` field. An array setter now keeps the container kind the property already carries, `rdf:Alt` as well as `rdf:Seq`, and two top-level properties of one name are neither read nor lost, as two fields of one name already were inside a structure. Retention, not merging: a setter handed values still replaces the property, so a caller that expects it to replace is not surprised.
A thirteen-row table of shapes is only as complete as the shapes someone thought of, and the shapes this round found at the top level were not on it. So the pin is generated, not listed: the four accessor pairs crossed with fifteen value shapes and four qualifier lists — 240 cases — each asserting that reading the property and writing what was read straight back gives the whole graph back, the property under test and the unrelated one beside it. The two tests that pinned a read-side skip with no writer opposite are restated as what the rule now says, and the container-kind test covers all three kinds rather than the one that had a test.
"A mistyped name fails there even if it were mistyped the same way twice" is half-true. The guard compares `KNOWN_TAGS` against the committed extraction, and nothing re-derives the extraction from the PDF, so a name mistyped identically in both places passes. What carries that residual is the artefact's own never-hand-edit banner, not a gate; the gate that would close it is filed as The deferral list gains the top-level half of the fidelity rule, which now matches the structure-internal half: a property the setter could not write back reads as absent and is left untouched rather than rewritten.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the breadth half of
gamut-iptc's v1 deferral list — the modelling gap issue #422 namesagainst exiv2 — without touching the reconciliation keystone.
Typed models for the structured IPTC properties (
crates/gamut-iptc/src/extension.rs, new).Iptc4xmpCore:CreatorContactInfo,Iptc4xmpExt:ImageRegion(withRegionBoundary,RegionBoundaryPointandEntity),Iptc4xmpExt:ArtworkOrObjectandplus:Licensorbecome typedprojections over the XMP graph, each with
from_xmp/to_xmpand an accessor pair onPhotoMetadata— the shapegamut_exif::GpsInfo::from_ifd/to_ifdalready uses.A projection is narrower than the graph, so every one of the seven types keeps what it cannot
express. Retention is decided by round-trippability, not by readability: a field becomes part of
the typed value only when the property the writer will emit for that value reproduces the field
that was read — same value, same RDF container kind, same qualifiers. Everything else stays in that
type's
otherlist verbatim and is re-emitted after the modelled fields.That one rule covers three classes at once: a field the model does not name (a vendor extension, or
the "any other metadata property" an
ImageRegionmay carry); a field it names but cannot read (acoordinate whose text is not a number, an identifier holding a structure); and — the class an
earlier read-side rule silently destroyed — a field it can read but could not write back as it
stands. Thirteen shapes fall in that third class, and the module derives the list from its own shape
corpus rather than restating it:
text: a URL held asrdf:resource<x rdf:resource="…"/>became<x>…</x>text: a value carrying a qualifierxml:lang="fr"onCiAdrCity) was droppedlang alt: another language beside the defaultlang alt: plain text where an alternative belongsrdf:Altlist: anrdf:Altwhere an array belongsBag, destroying every language taglist: anrdf:Seqwhere anrdf:Bagbelongslist: an item that is not textlist: an item held asrdf:resourcenumber: text with no XMPRealvalueNaN, an infinity and1e400were destroyed — read, marked consumed, then refused on writenested: a structure carrying a qualifiernested array: a bare structure where an array belongsnested array: anrdf:Bagwhere anrdf:Seqbelongsnested array: an item that is not a structureReading a structure and writing it back now changes nothing but three idempotent re-spellings:
a structure's fields come back in the model's order (they are an unordered set, XMP Part 1 §6.3.3),
a number may be re-spelled (
0.50→0.5), and anX-Defaulttag is re-cased (Part 1 §8.2.2.4matches it case-insensitively). All three are documented on the module.
The same rule applies to the property, not only to the fields inside it. The four accessor pairs
on
PhotoMetadatareport a value only when writing it back gives the property back; anything elsereads as absent and is left exactly where it lies, because at the top level the graph is the
retention list a structure keeps in its
otherfield. That closes seven shapes a read-then-setthrough those pairs used to destroy — an array member that is not a structure, a qualifier on the
property, a qualifier on an
rdf:li, anrdf:Altrewritten tordf:Bag, a bare structurerewritten to a Bag, an array or structure holding nothing at all, and a qualifier on the
contact-info property. An array setter now keeps the container kind the property already carries
(
AltandBagas well asSeq), and two top-level properties of one name are neither read norlost, as two fields of one name already were inside a structure. It is pinned by a generated cross
of every accessor pair × every value shape × every qualifier list — 240 cases — rather than by a
hand list of the shapes someone thought of.
The complete IIM 4.2 record-1/record-2 tag table (
crates/gamut-iptc/src/iim.rs).KNOWN_TAGSgrows from 24 to 71 entries: every dataset IPTC-IIM 4.2 chapters 5 and 6 give a determinate
octet maximum — 14 Envelope + 56 Application rows — plus the one dataset from chapter 11,
7:10Size Mode, which the spec fixes at one octet. Transcribed fromreferences/iptc/iim-4.2.pdf, and pinned back to it: the standard sets every DataSet's name in acolumn of its own, which
pdftotext -bbox-layoutrecovers by position, so the names are comparedagainst the standard itself rather than against a hand-kept table. The table is descriptive only:
no
FIELD_MAProw references a newly named dataset, so reading, merging and writing arebyte-for-byte unchanged, and nothing outside
gamut-iptcreadsIimTagInfo.No breaking change: everything here is additive.
Validation
Run from the lane's worktree.
mise run fmt/fmt-checkcarry the__CARGO_TEST_ROOTprefix thatthe nested-worktree cargo-root artefact requires; workspace-wide gates ran inside a
MemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2.cargo test -p gamut-iptc --all-featurescargo clippy -p gamut-iptc --all-targets --all-features__CARGO_TEST_ROOT=… mise run fmt-checkmise run check-testsmise run check-commitsconvco checkreports no errors in 24 commitsmise run lintmise run testmise run mutants-difforigin/master, 152 mutants, 144 caught, 8 unviable, 0 missedmise run check-release-deps/check-ffi-features/check-ffi-headerwere not run: noCargo.tomland no public C-surface type changed.mise run coveragewas not run here; the CIcoverage gate ran on this branch and passed.
Test technique, per
docs/testing.md:src/iim.rs): every dataset name in the table iscompared against the name the standard itself sets, in the DataSet column
pdftotext -bbox-layoutrecovers by position fromreferences/iptc/iim-4.2.pdf.tests/data/extract-iim-names.pyperforms the extraction and writestests/data/iim-4.2-dataset-names.tsv; the artefact is committed with the command thatregenerates it recorded beside it, because
pdftotextis a system package the toolchain does notprovision. Renaming
2:92to "Sublocation" now fails the gate; the six datasets the standardnames and gamut deliberately does not are pinned as absent, in the same place.
src/iim.rs): the columns the standard states in proserather than in a column — octet maximum, repeatability and value kind — are compared against
exiv2's independent transcription of the same chapters, parsed at test time from
third_party/exiv2/{include/exiv2/datasets.hpp,src/datasets.cpp}. Both guards are inline, not intests/, because they read the non-pubKNOWN_TAGSand.cargo/mutants.tomlsetstest_workspace = false.tests/techreference.rs): each modelled structure's field set is re-derived fromthe vendored
ipmd_structat test time and compared with whatto_xmpemits, so an invented,missed or mis-prefixed field fails rather than drifts. The four top-level properties are pinned to
the
ipmd_topXMPidand structure type, and their absence of anIIMid— what makes astructured property unable to conflict — is pinned there too, not merely documented. This is the
issue's acceptance criterion.
tests/oracle.rs): a new exiv2 cross-check over a stream spanning the Enveloperecord and the Application datasets outside the XMP-mapped subset. The existing fixture is
record 2 only, so this is the first record-1 leg.
src/extension.rs): one corpus states every shape a modelledfield can arrive in — the canonical form of each field kind and every departure from it a graph
can carry.
every_shape_survives_a_read_modify_write_unchangeddrives the module's stated lawover all of them, and
retention_covers_every_shape_the_typed_read_parses_but_cannot_write_backderives the thirteen-row table above from the same corpus rather than listing it, so the docs
cannot drift from the code.
src/extension.rs): the reproduction relation is stated clause by clause, onetest per clause — the numeric re-spelling, the URI target, a structure's fields as an unordered
set, a qualifier list matched pairwise, and the language tag as the one qualifier matched without
regard to case. Each fails for its own reason.
Each retention fix was checked against its falsifier before the fix was committed — the mutation
that reintroduces the defect was applied to the fixed tree and the suite re-run:
every_shape_survives_a_read_modify_write_unchanged,retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back,a_non_finite_coordinate_is_neither_written_nor_destroyed,a_bare_structure_reads_as_a_one_element_sequence,a_language_alternative_keeps_the_languages_beside_the_defaulta_field_the_model_cannot_read_is_kept_verbatim(only)a_default_entry_tagged_in_another_case_is_still_the_defaulta_retained_field_is_written_when_the_modelled_field_is_absent,every_structure_keeps_the_field_it_does_not_model,image_region_round_trips_and_keeps_unmodeled_propertiesEntityaloneevery_structure_keeps_the_field_it_does_not_model(only)reading_an_array_and_setting_it_back_keeps_every_member(only)setting_an_array_keeps_the_container_kind_the_property_already_has(only)2:25,2:92,7:10each tried)tag_table_names_match_the_standards_own_dataset_namesClosing round (the top-level reproduction rule)
Commands run for the four commits
660c3bb3,ff2b36ea,7d8eb755,cd0d6969, in a nestedworktree. Every row below was re-run on the final commit; the counts are that run's.
fmt-checkcarries the__CARGO_TEST_ROOTprefix the nested-worktree cargo-root artefact requires;the workspace-wide gates ran inside a
MemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2.cargo test -p gamut-iptc --all-featurescargo clippy -p gamut-iptc --all-targets --all-features -- -D warnings__CARGO_TEST_ROOT=… mise run fmt-checkmise run check-testsmise run check-commitsconvco checkreports no errors in 28 commitsmise run lintmise run testtest result: ok, 0 failuresmise run mutants-diffCI on
7d8eb755(the first three commits): Format & Metadata, Clippy & Doctests, Coverage (testgate) and all four
Incremental (PR diff)mutation shards pass. The fourth commit is moduledocumentation only. CI then ran again on the head
cd0d6969: Format & Metadata,Clippy & Doctests, Coverage (test gate) and all four
Incremental (PR diff)shards pass theretoo.
The mutation base, and how it was established.
mise run mutants-diffselects againstorigin/master.git merge-base origin/master HEADis6a75ec4a, which isgit rev-parse origin/masterexactly — so this branch is a direct descendant of master with 27 commits of itsown and nothing of another pull request folded in. No
GAMUT_MUTANTS_BASEoverride applies.check-release-deps/check-ffi-features/check-ffi-headerwere not run: noCargo.tomlandno public C-surface type changed.
coveragewas not run: no new module, and the change is densewith tests.
What the mutation gate cannot see.
KNOWN_TAGSgenerates no mutants at all — it is data,and a wrong constant in a table is not a mutation anyone generates. Its whole defence is the two
drift guards, and one of them compares the table against a committed artefact rather than against
the PDF (issue #623, and the corrected wording in
STATUS.md). Separately, the ordering clause ofthe reproduction relation has no mutant either: see the unresolved notes.
Each new clause was checked against its own falsifier, by reverting the clause on the fixed tree
and re-running the suite:
every_top_level_shape_survives_a_read_modify_write_unchanged,a_bare_structure_where_an_array_belongs_reads_as_absent_at_both_levels,an_array_member_the_model_cannot_write_back_makes_the_property_read_as_absentrdf:Altcontainer kindsetting_an_array_keeps_the_container_kind_the_property_already_has(only)every_top_level_shape_survives_a_read_modify_write_unchanged,two_top_level_properties_of_one_name_are_neither_read_nor_losttwo_top_level_properties_of_one_name_are_neither_read_nor_lost(only)Risks and rollout
and the
extensionmodule is new surface.IPTC_NAMESPACESgains PLUS, soPhotoMetadata::from_xmpnow retainsplus:properties it previously dropped. Stated plainly:an XMP graph carrying only licensing-namespace properties now reports IPTC metadata where it
previously reported none. That is required for the
Licensormodel to be reachable at all —the IPTC Extension defines
plus:Licensorin the PLUS namespace — and it is additive, but acaller that treats "no IPTC metadata" as a branch will now take the other branch for such a file.
The callers checked, by grep for
gamut_iptcacrosscrates/andtooling/: the onlyconsumer outside this crate is
gamut-metadata, and it branches on this in two places.Metadata::iptc()(crates/gamut-metadata/src/metadata.rs) returnsSomewhen the XMP carriesany IPTC-namespace property, so a PLUS-only graph now yields
Some(PhotoMetadata)where itpreviously yielded
None— that is the change, stated.MetadataEmbedder::encode_iim(
crates/gamut-metadata/src/embed.rs) calls it and then checksblock.datasets.is_empty(), andplus:properties have no IIM counterpart, so the emitted IIM block isNoneeither way: itsobservable behaviour is unchanged.
crates/gamut/src/lib.rsre-exports only; the oracle crateunder
tooling/builds exiv2 and does not call this path.A read-modify-write preserves every field the model cannot reproduce; the three differences that
remain are the idempotent re-spellings listed in the summary. The projection still reports
such a field as absent rather than widening the model to carry it — that is the deferral filed as
gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609, not a loss.
value the writer would emit — plus, for a structure field, a throwaway conversion of the
structure below it. IPTC structures nest three deep at most and hold a handful of fields each,
so the cost is bounded by the size of the structure being read; nothing here is on an encoder's
pixel path.
creator_contact_info,image_regions,artwork_or_objectsandlicensorsnow return nothingfor a property they would have destroyed on the way back out. Concretely: a bare structure written
where the standard puts an array of structures used to read as that array's single element and now
reads as absent; an array holding one member that is not a structure used to report the members
that were, and now reports none. All of this surface is new in this pull request — none of it is on
master — so no released behaviour changes, and nothing outside
gamut-iptccalls these four pairs(checked by grep across
crates/andtooling/; the only hits are this crate's own tests). Thedata is not lost in any of these cases: the property is left untouched and reachable through
PhotoMetadata::xmp. Whether the model should widen so these read as a value rather than asabsent is the same open question as gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609.
gamut-iptc; release-plz will take a minor bump. NoCargo.tomlchanged, so
check-release-deps/check-ffi-featuresare unaffected.Issue
Refs #422. NotCloses, because six pieces are filed rather than delivered:reached through accessors?
the model widen?
standard (filed in this round; see decision 33).
Decisions taken
This is an unattended run. No human approved this plan; the decision record below is what a
human reads afterwards. The user's own words declaring the run unattended, verbatim:
Read the record with this correction in hand. Decision 19 below (the
rdf:Altleniency)is superseded; its recorded evidence was wrong. The entry is left frozen as written, and the
correction is decision 26, appended at the end of this section. Decisions 13 (retention by
consumption) and 12 (a non-finite coordinate skipped on emit) are likewise superseded, by
decisions 22 and 23.
The frozen decision record this lane was launched with, verbatim:
Appended during delivery:
The second review round's decisions, delivered in commits
920a9f5f,f9b03b9d,64f0c4d0,5306b1d9and31306000. They are restated from the code that landed, not quoted:the round-2 record was never written into this body, and this lane does not hold its text (see
decision 21):
The third review round's decision record, verbatim as this lane received it:
Appended by this lane while delivering that record:
The fourth review round's decision record, verbatim as this lane received it:
Appended by this lane while delivering that record:
The fifth review round's decision record, verbatim as this lane received it:
Appended by this lane while delivering that record:
Unresolved review notes
photo_metadata::lang_altreads the first alternative, not thex-defaultone. It is mastercode, not this pull request's, and it does not destroy the other languages the way the extension
helper did (see decision 20). Anyone changing it should give it the same
xml:langmatch.Vec<XmpProperty>on all seven types. Whether itshould instead be reached through accessors, as
AGENTS.md's C-portability convention suggestsfor a payload, is open and filed as gamut-iptc: should an extension structure's retained fields be a public field or reached through accessors? #591 rather than decided here.
retained verbatim and survives a read-modify-write — but the typed view does not report the
value either. Whether the model should widen to carry it is filed as gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609 (decision 27).
to_xmpis infallible and silently omits a value the XMP type cannot hold. Whether emissionshould be fallible instead is filed as gamut-iptc: should extension structure emission be fallible? #573.
(
f9b03b9d). It cannot be corrected without rewriting published history, which this run may notdo; the correction is recorded in decision 24 instead.
same_arraycompares two arrays item by item in order, so a writer that reordered an array's items would not
reproduce what was read. No writer reorders, and cargo-mutants generates no mutation that
reorders, so the clause is currently an equivalent mutation: nothing can distinguish it from a
version that compared the items as a set. It is dormant rather than wrong — an array's items are
ordered (XMP Part 1 §6.3.4) and the clause is the correct statement — but it is the one clause
that would silently survive if a writer ever did reorder. No test is manufactured for it here;
anyone adding a writer that reorders should bring the falsifier with it.
KNOWN_TAGSis data and generates no mutants, so the mutation gate says nothing about it ineither direction. Its defence is the two drift guards described under Validation, and the residual
in the name guard is gamut-iptc: nothing re-derives the committed IIM 4.2 dataset-name table from the standard #623 (decisions 34 and 36).