From f85d132ebdb3b26c1d80d6414ec39220c226334d Mon Sep 17 00:00:00 2001 From: "marcin p. joachimiak" <4625870+realmarcin@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:56:16 -0700 Subject: [PATCH 1/5] Preserve source assertions while constructing bare legacy DOI values Refs #4670 --- .claude/agents/scripts/d4d_builder.py | 24 +- notes/legacy_doi_2026-10-08/README.md | 58 +++++ src/data_sheets_schema/legacy_doi.py | 41 +++ .../utils/d4d_builder.py | 17 +- .../test_d4d_builder.py | 4 +- tests/test_rocrate/test_legacy_doi.py | 236 ++++++++++++++++++ 6 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 notes/legacy_doi_2026-10-08/README.md create mode 100644 src/data_sheets_schema/legacy_doi.py create mode 100644 tests/test_rocrate/test_legacy_doi.py diff --git a/.claude/agents/scripts/d4d_builder.py b/.claude/agents/scripts/d4d_builder.py index 877d03278..eebb37749 100644 --- a/.claude/agents/scripts/d4d_builder.py +++ b/.claude/agents/scripts/d4d_builder.py @@ -7,8 +7,17 @@ """ from datetime import datetime +from pathlib import Path +import sys from typing import Any, Dict, List, Optional, Union +# Like the parser wrapper, support direct execution outside the checkout. +_source = Path(__file__).resolve().parents[3] / 'src' +if (_source / 'data_sheets_schema/legacy_doi.py').is_file() and str(_source) not in sys.path: + sys.path.insert(0, str(_source)) + +from data_sheets_schema.legacy_doi import doi_value, source_value + class D4DBuilder: """Build D4D YAML structure from mapped RO-Crate data.""" @@ -54,10 +63,13 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: # Try to extract value from RO-Crate value = None - for rc_prop in rocrate_props: - value = rocrate_parser.get_property(rc_prop) - if value is not None: - break + if d4d_field == 'doi': + value = source_value(rocrate_parser, rocrate_props) + else: + for rc_prop in rocrate_props: + value = rocrate_parser.get_property(rc_prop) + if value is not None: + break if value is not None: # Apply transformations based on field type @@ -85,6 +97,10 @@ def apply_field_transformation(self, field_name: str, value: Any) -> Any: if not mapping_info: return value + # Dataset.doi requires the bare form, independently of stale TSV types. + if field_name == 'doi': + return doi_value(value) + field_type = mapping_info.get('Type', '').lower() # Date transformations diff --git a/notes/legacy_doi_2026-10-08/README.md b/notes/legacy_doi_2026-10-08/README.md new file mode 100644 index 000000000..68debe14a --- /dev/null +++ b/notes/legacy_doi_2026-10-08/README.md @@ -0,0 +1,58 @@ +# Legacy DOI construction + +This is the bounded repair in +[#4670](https://github.com/bridge2ai/data-sheets-schema/issues/4670). + +The packaged and hidden TSV builders now give the `doi` target its own +construction rule. Previously a bare source DOI such as `10.1234/MixedCase` +became `https://doi.org/10.1234/MixedCase`, contradicting the Dataset schema's +bare-DOI requirement. Generic URI targets keep their existing behavior. + +Recognized scalar `doi:`, HTTP(S) `doi.org`, and HTTP(S) `dx.doi.org` forms use +the existing `scope.bare_doi` repair parser. Prefix matching is case insensitive; +suffix case is retained. That parser removes surrounding whitespace and trailing +slashes from a prefixed value. Strings already starting with `10.` stay exactly +as supplied, including schema-valid bare forms outside the repair parser's +narrower grammar. The mandatory final Dataset validator still decides whether +a retained value is valid. + +Malformed strings, objects, booleans, numbers and lists stay intact in drafts. +A singleton, repeated, mixed or conflicting list never contributes just its +first or recognizable DOI. No source assertion is dropped to obtain a valid +scalar, and returned mutable DOI values do not alias the parser's source data. +Missing/null source properties retain the builder's existing missing-value rule. + +When an explicitly selected TSV maps several properties to `doi`, every +nonmissing root assertion is checked. Exact repeated scalar strings agree; +different strings or competing nonscalar values refuse construction. This +includes different spellings of the same DOI: the legacy builder has no +sidecar that retains the discarded alternative, so the caller must explicitly +disambiguate the selected mapping. Member properties cannot fill a missing root +value or participate in its conflict decision. Other targets retain their +existing property precedence. + +The current default TSV is unchanged and still omits required `Dataset.id`. +This repair does not make that producer generally schema valid, reroute it, +mint IDs, reinterpret person roles, convert rounded sizes to exact byte counts, +or rewrite historical outputs. Parent issues +[#4594](https://github.com/bridge2ai/data-sheets-schema/issues/4594) and +[#2915](https://github.com/bridge2ai/data-sheets-schema/issues/2915) remain open. +Neither source coverage nor semantic equivalence follows from this correction; +row retirement is not a coverage gain, and intact text does not establish +`exactMatch`/`none`. + +## Validation plan + +No tests or project replays have run for this draft. Focused controls use both +real builders and the actual closed Dataset validator, plus packaged/hidden +publishers and the API batch path with explicit valid-ID mappings. They cover +accepted forms and case, broad-schema bare values, malformed and non-scalar +preservation, competing source properties, root/member isolation, unchanged +generic URI handling, direct hidden-script imports, and preserved source, +mapping and destination bytes after refusal. A later invalid batch DOI must +prevent every destination replacement. + +Run the new `tests/test_rocrate/test_legacy_doi.py` alongside the existing +builder, publication, API result-contract and wrapper regressions after +independent review. Keep all five retained-project legacy replays and their +historical outputs under the parent issue's separate obligations. diff --git a/src/data_sheets_schema/legacy_doi.py b/src/data_sheets_schema/legacy_doi.py new file mode 100644 index 000000000..8f9a0df3b --- /dev/null +++ b/src/data_sheets_schema/legacy_doi.py @@ -0,0 +1,41 @@ +"""DOI-only construction for the legacy TSV builders; never a validation gate.""" +from copy import deepcopy + +from data_sheets_schema.scope import bare_doi + + +def doi_value(value): + """Repair recognised scalar prefixes without discarding source assertions. + + Bare strings stay exactly as supplied, including schema-valid forms outside + the repair parser's narrower grammar. Malformed strings, objects and lists + also stay intact for the mandatory Dataset publication validator. In + particular, a list never contributes only its first or recognisable DOI. + """ + if isinstance(value, str) and not value.startswith('10.'): + repaired = bare_doi(value) + if repaired is not None: + return repaired + return deepcopy(value) + + +def source_value(parser, properties): + """Read all declared DOI sources, refusing unequal competing assertions. + + Exact repeated scalar strings need no precedence decision. Even equivalent + DOI spellings in different source properties are otherwise left for the + caller to disambiguate, rather than silently dropping one assertion. + Other targets retain the legacy builder's ordinary first-property policy. + """ + present = [(name, parser.get_property(name)) for name in dict.fromkeys(properties)] + present = [(name, value) for name, value in present if value is not None] + if not present: + return None + first = present[0][1] + if len(present) > 1 and not ( + isinstance(first, str) + and all(isinstance(value, str) and value == first for _, value in present) + ): + names = ', '.join(name for name, _ in present) + raise ValueError(f"Conflicting DOI source assertions ({names}); no value was selected") + return first diff --git a/src/fairscape_integration/utils/d4d_builder.py b/src/fairscape_integration/utils/d4d_builder.py index 07baf38e1..016f21f54 100644 --- a/src/fairscape_integration/utils/d4d_builder.py +++ b/src/fairscape_integration/utils/d4d_builder.py @@ -9,6 +9,8 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Union +from data_sheets_schema.legacy_doi import doi_value, source_value + class D4DBuilder: """Build D4D YAML structure from mapped RO-Crate data.""" @@ -58,10 +60,13 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: # Try to extract value from RO-Crate value = None - for rc_prop in rocrate_props: - value = rocrate_parser.get_property(rc_prop) - if value is not None: - break + if d4d_field == 'doi': + value = source_value(rocrate_parser, rocrate_props) + else: + for rc_prop in rocrate_props: + value = rocrate_parser.get_property(rc_prop) + if value is not None: + break if value is not None: # Apply transformations based on field type @@ -90,6 +95,10 @@ def apply_field_transformation(self, field_name: str, value: Any) -> Any: if not mapping_info: return value + # Dataset.doi requires the bare form, independently of stale TSV types. + if field_name == 'doi': + return doi_value(value) + field_type = mapping_info.get('Type', '').lower() # Date transformations diff --git a/tests/test_fairscape_integration/test_d4d_builder.py b/tests/test_fairscape_integration/test_d4d_builder.py index b52b44430..ce421d1b4 100644 --- a/tests/test_fairscape_integration/test_d4d_builder.py +++ b/tests/test_fairscape_integration/test_d4d_builder.py @@ -198,11 +198,11 @@ def test_transform_list_none(self): self.assertIsNone(result) def test_transform_uri_field(self): - """Test URI transformation.""" + """The DOI slot takes the bare form, not the generic URI form.""" dataset = self.builder.build_dataset(self.rocrate_parser) self.assertIn('doi', dataset) - self.assertEqual(dataset['doi'], 'https://doi.org/10.1234/test') + self.assertEqual(dataset['doi'], '10.1234/test') def test_transform_uri_doi(self): """Test DOI transformation (add https prefix).""" diff --git a/tests/test_rocrate/test_legacy_doi.py b/tests/test_rocrate/test_legacy_doi.py new file mode 100644 index 000000000..276b855bc --- /dev/null +++ b/tests/test_rocrate/test_legacy_doi.py @@ -0,0 +1,236 @@ +"""Both real legacy constructors retain evidence while repairing only DOI form.""" +from copy import deepcopy +import importlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys + +from click.testing import CliRunner +import pytest +import yaml + +from data_sheets_schema import legacy_publication as publication +from .test_legacy_root_gates import legacy_imports + + +REPO = Path(__file__).resolve().parents[2] +BARE = '10.1234/MixedCase' +ID = 'https://example.org/retained-record' +SENTINEL = b'previous reviewed output\n' +MISSING = object() + + +@pytest.fixture(params=['packaged', 'hidden']) +def implementation(request): + if request.param == 'hidden': + with legacy_imports(): + yield tuple(importlib.import_module(name) for name in + ('d4d_builder', 'mapping_loader', 'rocrate_parser')) + else: + yield tuple(importlib.import_module('fairscape_integration.utils.' + name) + for name in ('d4d_builder', 'mapping_loader', 'rocrate_parser')) + + +def mapping(tmp_path, *, properties='identifier', field_type='URI', include_id=True): + path = tmp_path / 'selected.tsv' + path.write_text( + 'D4D Property\tType\tFAIRSCAPE RO-Crate Property\t' + 'Covered by FAIRSCAPE? Yes =1; No = 0\tDirect mapping? Yes =1; No = 0\n' + + ('id\tstr\trecordId\t1\t1\n' if include_id else '') + + f'doi\t{field_type}\t{properties}\t1\t1\n' + + 'title\tstr\tname\t1\t1\n' + + 'download_url\turi\tcontentUrl\t1\t1\n', encoding='utf-8') + return path + + +def crate(tmp_path, value=MISSING, *, second=MISSING, reverse=False, name='input.json'): + root = {'@id': './', '@type': 'Dataset', 'recordId': ID, 'name': 'Source title', + 'contentUrl': 'https://example.org/retained-download'} + if value is not MISSING: + root['identifier'] = deepcopy(value) + if second is not MISSING: + root['alternateIdentifier'] = deepcopy(second) + graph = [ + {'@id': 'ro-crate-metadata.json', '@type': 'CreativeWork', 'about': {'@id': './'}}, + root, + {'@id': 'child', '@type': 'Dataset', 'identifier': '10.5678/Member', + 'alternateIdentifier': '10.5678/OtherMember'}, + ] + path = tmp_path / name + path.write_text(json.dumps({'@graph': graph[::-1] if reverse else graph}), encoding='utf-8') + return path + + +def build(implementation, source, table): + builder, loader, parser = implementation + return builder.D4DBuilder(loader.MappingLoader(str(table))).build_dataset( + parser.ROCrateParser(str(source))) + + +@pytest.mark.parametrize('prefix', ['', 'doi:', 'DOI:', 'http://doi.org/', + 'https://doi.org/', 'http://dx.doi.org/', 'https://dx.doi.org/', 'HTTPS://DOI.ORG/']) +def test_real_builders_normalize_scalar_doi_and_validate(implementation, tmp_path, prefix): + source, table = crate(tmp_path, prefix + BARE), mapping(tmp_path) + before = {p: p.read_bytes() for p in (source, table)} + record = build(implementation, source, table) + assert record['doi'] == BARE + assert record['download_url'] == 'https://example.org/retained-download' + assert yaml.safe_load(publication.prepare_dataset(record)) == record + assert {p: p.read_bytes() for p in before} == before + + +@pytest.mark.parametrize('value', ['10.1234567890/LongRegistrant', '10.1234/Space suffix', + '10.1234/Tab\tsuffix', '10.1234/Trailing/']) +def test_schema_valid_bare_values_are_not_narrowed_or_trimmed(implementation, tmp_path, value): + record = build(implementation, crate(tmp_path, value), mapping(tmp_path)) + assert record['doi'] == value + assert yaml.safe_load(publication.prepare_dataset(record))['doi'] == value + + +INVALID = ['', '10.', '10.123/x', 'not a DOI', 'ark:12345/record', + 'https://example.org/?persistentId=doi:10.1234/Example', + 'https://doi.org/10.1234/two words', {'@id': 'doi:' + BARE}, False, 0, + [], [BARE], [BARE, BARE], [BARE, '10.5678/Conflicting'], [BARE, None], + ['not DOI', 'doi:' + BARE]] + + +@pytest.mark.parametrize('value', INVALID) +def test_invalid_and_list_assertions_survive_draft_and_fail_publication( + implementation, tmp_path, value): + source, table = crate(tmp_path, value), mapping(tmp_path) + before = source.read_bytes() + record = build(implementation, source, table) + assert type(record['doi']) is type(value) and record['doi'] == value + with pytest.raises(publication.PublicationError, match='doi|DOI'): + publication.prepare_dataset(record) + assert source.read_bytes() == before + + +@pytest.mark.parametrize('field_type', ['', 'str', 'int', 'bool', 'list[str]', 'date', 'SomeEnum']) +def test_stale_tsv_type_cannot_override_the_doi_slot(implementation, tmp_path, field_type): + record = build(implementation, crate(tmp_path, 'doi:' + BARE), + mapping(tmp_path, field_type=field_type)) + assert record['doi'] == BARE + + +@pytest.mark.parametrize('second', ['10.5678/Other', BARE.lower(), 'doi:' + BARE, + 'not DOI', False, [BARE]]) +@pytest.mark.parametrize('reverse', [False, True]) +def test_competing_root_properties_refuse_in_either_mapping_and_graph_order( + implementation, tmp_path, second, reverse): + names = ['identifier', 'alternateIdentifier'] + source = crate(tmp_path, BARE, second=second, reverse=reverse) + table = mapping(tmp_path, properties=','.join(names[::-1] if reverse else names)) + before = {p: p.read_bytes() for p in (source, table)} + with pytest.raises(ValueError, match='Conflicting DOI source assertions'): + build(implementation, source, table) + assert {p: p.read_bytes() for p in before} == before + + +@pytest.mark.parametrize('value,second,expected', [ + (MISSING, MISSING, MISSING), (None, BARE, BARE), (BARE, BARE, BARE)]) +def test_missing_root_never_uses_member_and_identical_sources_need_no_choice( + implementation, tmp_path, value, second, expected): + table = mapping(tmp_path, properties='identifier,alternateIdentifier') + record = build(implementation, crate(tmp_path, value, second=second, reverse=True), table) + if expected is MISSING: + assert 'doi' not in record + else: + assert record['doi'] == expected + + +def test_retained_draft_objects_do_not_alias_parser_input(implementation, tmp_path): + builder, loader, parser_module = implementation + parser = parser_module.ROCrateParser(str(crate(tmp_path, [{'@id': 'doi:' + BARE}]))) + before = deepcopy(parser.require_root_dataset()) + record = builder.D4DBuilder(loader.MappingLoader(str(mapping(tmp_path)))).build_dataset(parser) + record['doi'][0]['@id'] = 'changed local draft' + assert parser.require_root_dataset() == before + + +@pytest.mark.parametrize('entrypoint', ['packaged', 'hidden']) +@pytest.mark.parametrize('kind', ['valid', 'malformed', 'list', 'conflict', 'missing_id']) +def test_real_publishers_preserve_destinations_on_unaccepted_doi( + tmp_path, monkeypatch, entrypoint, kind): + value = {'valid': 'doi:' + BARE, 'malformed': 'not DOI', 'list': [BARE], + 'conflict': BARE, 'missing_id': 'doi:' + BARE}[kind] + source = crate(tmp_path, value, second='10.5678/Other' if kind == 'conflict' else MISSING) + table = mapping(tmp_path, properties='identifier,alternateIdentifier', include_id=kind != 'missing_id') + output = tmp_path / 'record.yaml' + report = output.with_name('record_report.txt' if entrypoint == 'packaged' + else 'transformation_report.txt') + output.write_bytes(SENTINEL); report.write_bytes(SENTINEL) + before = {p: p.read_bytes() for p in (source, table)} + if entrypoint == 'packaged': + from fairscape_integration.cli import cli + result = CliRunner().invoke(cli, ['transform', str(source), '-m', str(table), + '-o', str(output), '--report']) + status = result.exit_code + else: + with legacy_imports(): + single = importlib.import_module('rocrate_to_d4d') + monkeypatch.setattr(sys, 'argv', ['transform', '--input', str(source), + '-m', str(table), '-o', str(output)]) + status = single.main() + if kind == 'valid': + assert status == 0 + assert yaml.safe_load(output.read_bytes())['doi'] == BARE + else: + assert status == 1 + assert output.read_bytes() == report.read_bytes() == SENTINEL + assert {p: p.read_bytes() for p in before} == before + + +def test_unchanged_actual_default_table_still_requires_root_id(implementation, tmp_path): + table = REPO / 'data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv' + before = table.read_bytes() + record = build(implementation, crate(tmp_path, 'doi:' + BARE), table) + assert record['doi'] == BARE and 'id' not in record + with pytest.raises(publication.PublicationError, match='id'): + publication.prepare_dataset(record) + assert table.read_bytes() == before + + +@pytest.mark.parametrize('late_invalid', [False, True]) +def test_real_api_batch_validates_all_dois_before_first_publication(tmp_path, late_invalid): + inputs, output = tmp_path / 'inputs', tmp_path / 'outputs' + inputs.mkdir(); output.mkdir() + sources = [crate(inputs, 'doi:' + BARE, name='a.json'), + crate(inputs, [BARE, None] if late_invalid else 'https://doi.org/' + BARE, + name='z.json')] + table = mapping(tmp_path) + destinations = [output / f'{source.stem}_d4d.yaml' for source in sources] + for destination in destinations: + destination.write_bytes(SENTINEL) + before = {path: path.read_bytes() for path in [*sources, table]} + with legacy_imports(): + spec = importlib.util.spec_from_file_location('_root_gates_transform_api', + REPO / 'src/transformation/transform_api.py') + api = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = api + spec.loader.exec_module(api) + def publish_batch(): + return api.batch_transform_rocrates(inputs, output, validate=False, + mapping_file=table, result_contract='dataset_v1') + if late_invalid: + with pytest.raises(publication.PublicationError, match='z.json.*doi'): + publish_batch() + assert [path.read_bytes() for path in destinations] == [SENTINEL, SENTINEL] + else: + assert len(publish_batch()) == 2 + assert [yaml.safe_load(path.read_bytes())['doi'] for path in destinations] == [BARE, BARE] + assert {path: path.read_bytes() for path in before} == before + + +def test_hidden_builder_direct_script_keeps_source_checkout_import(tmp_path): + source, table = crate(tmp_path, 'doi:' + BARE), mapping(tmp_path) + env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1') + env.pop('PYTHONPATH', None) + result = subprocess.run([sys.executable, '-B', + str(REPO / '.claude/agents/scripts/d4d_builder.py'), str(table), str(source)], + cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert f'doi: {BARE}' in result.stdout From ffdc4a67ef3925a8f7cedab2c0c783b967a6d823 Mon Sep 17 00:00:00 2001 From: "marcin p. joachimiak" <4625870+realmarcin@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:59:23 -0700 Subject: [PATCH 2/5] docs: record legacy DOI validation (refs #4670) --- notes/legacy_doi_2026-10-08/README.md | 55 +++-- notes/legacy_doi_2026-10-08/validation.json | 257 ++++++++++++++++++++ 2 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 notes/legacy_doi_2026-10-08/validation.json diff --git a/notes/legacy_doi_2026-10-08/README.md b/notes/legacy_doi_2026-10-08/README.md index 68debe14a..d93b5a8ae 100644 --- a/notes/legacy_doi_2026-10-08/README.md +++ b/notes/legacy_doi_2026-10-08/README.md @@ -41,18 +41,43 @@ Neither source coverage nor semantic equivalence follows from this correction; row retirement is not a coverage gain, and intact text does not establish `exactMatch`/`none`. -## Validation plan - -No tests or project replays have run for this draft. Focused controls use both -real builders and the actual closed Dataset validator, plus packaged/hidden -publishers and the API batch path with explicit valid-ID mappings. They cover -accepted forms and case, broad-schema bare values, malformed and non-scalar -preservation, competing source properties, root/member isolation, unchanged -generic URI handling, direct hidden-script imports, and preserved source, -mapping and destination bytes after refusal. A later invalid batch DOI must -prevent every destination replacement. - -Run the new `tests/test_rocrate/test_legacy_doi.py` alongside the existing -builder, publication, API result-contract and wrapper regressions after -independent review. Keep all five retained-project legacy replays and their -historical outputs under the parent issue's separate obligations. +## Validation results + +The serialized eight-module run passed **318 tests**, with zero failures, +errors or skips, at commit +`f85d132ebdb3b26c1d80d6414ec39220c226334d`, tree +`5371e1734c2f7aeed14b1c0668c3154dbc3136e2`. The coordinator's terminal reported +43.51 seconds and 14 dependency deprecation warnings. JUnit records 43.473 +seconds; its XML does not encode the warning count or attest the tested commit. + +| Module | Passed | +| --- | ---: | +| `tests/test_rocrate/test_legacy_doi.py` | 117 | +| `tests/test_fairscape_integration/test_d4d_builder.py` | 37 | +| `tests/test_rocrate/test_legacy_publication.py` | 51 | +| `tests/test_rocrate/test_legacy_publication_adversarial.py` | 39 | +| `tests/test_rocrate/test_legacy_result_contract.py` | 19 | +| `tests/test_rocrate/test_legacy_envelope_adversarial.py` | 28 | +| `tests/test_cli/test_rocrate_transform_mapping.py` | 17 | +| `tests/test_cli/test_rocrate_transform_mapping_adversarial.py` | 10 | + +The 117 new cases exercise both real builders and the current closed Dataset +validator, packaged/hidden publishers, the real API batch path with an explicit +valid-ID mapping, and direct hidden-script use from another working directory. +They cover the accepted forms and preservation/refusal boundaries above, +including a later invalid batch DOI preventing every destination replacement. +Existing publication, result-contract and wrapper controls also passed. +Independent adversarial source review found no material issues before execution. + +[validation.json](validation.json) records the command with symbolic paths, +the eight module counts, and 26 selected production/test/resource/dependency +pins. Each selected working file was compared byte-for-byte with the tested Git +blob. These pins cover the named files, not a complete installed environment. +The retained external JUnit artifact `legacy-doi-tests-01.xml` is 51,571 bytes, +SHA-256 `30c50542813c258f877d4631d55341ab69cbe564b0afb2de163465a4d6ce3905`. +Only these evidence notes changed after the run; source and tests are unchanged. + +No real-project replay, provider call, scientific rating or new historical label +was produced. All five retained-input legacy replays and the remaining default +construction defects remain obligations of #4594; #2915 stays open. The successful +software controls establish this DOI slice, not overall legacy mapping repair. diff --git a/notes/legacy_doi_2026-10-08/validation.json b/notes/legacy_doi_2026-10-08/validation.json new file mode 100644 index 000000000..9f7325e12 --- /dev/null +++ b/notes/legacy_doi_2026-10-08/validation.json @@ -0,0 +1,257 @@ +{ + "format": "legacy_doi_construction_validation_v1", + "issue": 4670, + "open_parent_issues": [ + 4594, + 2915 + ], + "tested_commit": "f85d132ebdb3b26c1d80d6414ec39220c226334d", + "tested_tree": "5371e1734c2f7aeed14b1c0668c3154dbc3136e2", + "base_commit": "9bf9c31c57bbd48a21feb74de478806dea3f6d1f", + "verification": { + "coordinator_provenance": "The coordinator reports running this command at the tested clean commit; XML does not attest commit or environment.", + "independent_author_checks": [ + "Parsed every JUnit testcase and counted failure/error/skipped nodes.", + "Compared all selected working file bytes with the tested Git commit blobs." + ], + "scope": "Selected source, resources and dependency declarations; not an installed-environment or complete import closure." + }, + "tests": { + "passed": 318, + "failed": 0, + "errors": 0, + "skipped": 0, + "exit_code": 0, + "modules": { + "tests/test_rocrate/test_legacy_doi.py": 117, + "tests/test_fairscape_integration/test_d4d_builder.py": 37, + "tests/test_rocrate/test_legacy_publication.py": 51, + "tests/test_rocrate/test_legacy_publication_adversarial.py": 39, + "tests/test_rocrate/test_legacy_result_contract.py": 19, + "tests/test_rocrate/test_legacy_envelope_adversarial.py": 28, + "tests/test_cli/test_rocrate_transform_mapping.py": 17, + "tests/test_cli/test_rocrate_transform_mapping_adversarial.py": 10 + }, + "junit_seconds": 43.473, + "console_seconds": 43.51, + "warnings": { + "count": 14, + "kind": "dependency deprecation warnings", + "provenance": "coordinator terminal report; JUnit does not encode this count" + }, + "artifact": { + "name": "legacy-doi-tests-01.xml", + "bytes": 51571, + "sha256": "30c50542813c258f877d4631d55341ab69cbe564b0afb2de163465a4d6ce3905" + }, + "invocation": { + "cwd": "$CHECKOUT", + "environment": { + "PYTHONDONTWRITEBYTECODE": "1", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONPATH": "src:." + }, + "command": [ + "$PYTHON", + "-B", + "-m", + "pytest", + "tests/test_rocrate/test_legacy_doi.py", + "tests/test_fairscape_integration/test_d4d_builder.py", + "tests/test_rocrate/test_legacy_publication.py", + "tests/test_rocrate/test_legacy_publication_adversarial.py", + "tests/test_rocrate/test_legacy_result_contract.py", + "tests/test_rocrate/test_legacy_envelope_adversarial.py", + "tests/test_cli/test_rocrate_transform_mapping.py", + "tests/test_cli/test_rocrate_transform_mapping_adversarial.py", + "-q", + "-p", + "no:cacheprovider", + "--basetemp=$ARTIFACT_ROOT/legacy-doi-pytest-01", + "--junitxml=$ARTIFACT_ROOT/legacy-doi-tests-01.xml" + ], + "python": "CPython 3.13.12 in coordinator-selected virtual environment", + "path_variables": "PYTHON identifies that interpreter; ARTIFACT_ROOT is a separate external evidence directory. Private absolute paths are deliberately not published." + } + }, + "source_pins": [ + { + "path": "src/data_sheets_schema/legacy_doi.py", + "bytes": 1749, + "sha256": "bb30e9f5f1e3824c44707fce03fe8dab1798c374caaec033286c94b16a2a9d3f", + "matches_tested_git_blob": true + }, + { + "path": "src/fairscape_integration/utils/d4d_builder.py", + "bytes": 10025, + "sha256": "38a258d9efcf4706b98fc897af3d22c8df2570b63553945437780814469b939e", + "matches_tested_git_blob": true + }, + { + "path": ".claude/agents/scripts/d4d_builder.py", + "bytes": 10840, + "sha256": "d71f0fc6959a006b3d87b0458f6c12fa953e22b3dae03eb52569a2b6103ab251", + "matches_tested_git_blob": true + }, + { + "path": "src/data_sheets_schema/scope.py", + "bytes": 25356, + "sha256": "f586f60e1348c99bd2b0d04ce1025d3d998184e9de37b4de27abb515aa01d199", + "matches_tested_git_blob": true + }, + { + "path": "src/data_sheets_schema/legacy_publication.py", + "bytes": 6374, + "sha256": "faa5df37620ba6f005f7e9999ddd7653054c54808efc0988ccbd28a2a44e53a9", + "matches_tested_git_blob": true + }, + { + "path": "src/fairscape_integration/utils/rocrate_parser.py", + "bytes": 11273, + "sha256": "4c7a7f43a94b17893cde6917e75d4648bcf017f06f7aaa9e13f8cb92b83905ec", + "matches_tested_git_blob": true + }, + { + "path": ".claude/agents/scripts/rocrate_parser.py", + "bytes": 1930, + "sha256": "c50f88eecce97c2a53e81f58f6db0af94216ceb637322f6848c260dab7f80bfd", + "matches_tested_git_blob": true + }, + { + "path": "src/data_sheets_schema/rocrate_sources.py", + "bytes": 11731, + "sha256": "38c85ac04a1ccdcf3382024d438f48efa577b4871bea683a562a792595907a75", + "matches_tested_git_blob": true + }, + { + "path": "src/fairscape_integration/utils/mapping_loader.py", + "bytes": 6358, + "sha256": "d90341146c566ed5f03e71213a159b85ff3500cacce78e2f375bf22e4b2d2b67", + "matches_tested_git_blob": true + }, + { + "path": ".claude/agents/scripts/mapping_loader.py", + "bytes": 6587, + "sha256": "0214e4f250367a93fe97cccb3ae93bf1ee8c9047f6db1fcb82ff68c582bb3e2c", + "matches_tested_git_blob": true + }, + { + "path": "src/fairscape_integration/cli.py", + "bytes": 21252, + "sha256": "463a3e9a574ab8133c31bbc5a44895b02640842b76e754f844e927657ad418ad", + "matches_tested_git_blob": true + }, + { + "path": ".claude/agents/scripts/rocrate_to_d4d.py", + "bytes": 16361, + "sha256": "8c6cd41fb0e32fbc8c457208d863460ac462c76cd4c20f4215d2f90c6a9dce5e", + "matches_tested_git_blob": true + }, + { + "path": "src/transformation/transform_api.py", + "bytes": 34045, + "sha256": "b7602e5940d18e008f528dffa2b77846af64d8b7f76504219f4a52787b39ac39", + "matches_tested_git_blob": true + }, + { + "path": "src/data_sheets_schema/cli/rocrate.py", + "bytes": 19375, + "sha256": "371a32df2dd466e9bbc74ac35ebaaa175a06b2c8f7685a37157f07350b083e80", + "matches_tested_git_blob": true + }, + { + "path": "src/data_sheets_schema/schema/data_sheets_schema_all.yaml", + "bytes": 1442513, + "sha256": "eb543e1597b29599952359818bc741b0f23eaa3a30e7aa8c63e60212c7bbb92f", + "matches_tested_git_blob": true + }, + { + "path": "data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv", + "bytes": 24216, + "sha256": "28af70ed814690645ecf58b6bfd0a716d5300866c0018eaf07704387a410614e", + "matches_tested_git_blob": true + }, + { + "path": "pyproject.toml", + "bytes": 4446, + "sha256": "bac863ac39cc39d6767627e0a7d8fd085be693555e5220015afbe6aec45c0c8b", + "matches_tested_git_blob": true + }, + { + "path": "poetry.lock", + "bytes": 592155, + "sha256": "923ad962d2646e1a4be044a76ff6a66abf49c3e10ea87fc85ed3cd523a907e21", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_rocrate/test_legacy_doi.py", + "bytes": 11040, + "sha256": "ef2861e6983ead370f1b5779133d2f9d2a44259655604d4e7ddae72371dabed4", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_fairscape_integration/test_d4d_builder.py", + "bytes": 13549, + "sha256": "696a4d829adb15aebbdcdca13d0ef5bd8f931cbfccd64ef15f311700b672efeb", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_rocrate/test_legacy_publication.py", + "bytes": 19018, + "sha256": "b61058a2e214370d2f93eaaff49296bcfe410280be7b30873202713be35fa537", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_rocrate/test_legacy_publication_adversarial.py", + "bytes": 16106, + "sha256": "b1a132a5d8ca1aa8d7a06c9c5baf6b986dc385165dc70bc2669e35aa1a5744f2", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_rocrate/test_legacy_result_contract.py", + "bytes": 10932, + "sha256": "b95715c56bd1c7ac52eb97224fbeb8b37c69bca1e1e931264ed0acc4d0ea40e4", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_rocrate/test_legacy_envelope_adversarial.py", + "bytes": 17149, + "sha256": "97fe763933dfc33e14934668840c364e4b8ab58b52f48ef57fe11e1d7f0448bd", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_cli/test_rocrate_transform_mapping.py", + "bytes": 8103, + "sha256": "149b9a7b75aa35bd414418134671075424b58145ceb7347f9c1ac4ec335085b6", + "matches_tested_git_blob": true + }, + { + "path": "tests/test_cli/test_rocrate_transform_mapping_adversarial.py", + "bytes": 4708, + "sha256": "b619461ada2b2ce8431d7f10f318c061659dd2f10656dc03e038affeb4102224", + "matches_tested_git_blob": true + } + ], + "review": { + "kind": "independent read-only adversarial source review", + "result": "No material findings before the serialized focused run.", + "additional_execution": "No author or reviewer test/replay rerun during evidence preparation." + }, + "checked_behavior": [ + "Both legacy builders produce bare DOI from recognized scalar CURIE and HTTP(S) doi.org/dx.doi.org forms.", + "Existing bare spellings and malformed/list/non-scalar assertions remain intact; typed Dataset publication decides acceptance.", + "Competing root source properties refuse before a first-property choice, including distinct spellings of the same DOI.", + "Root/member isolation, unchanged non-DOI URI behavior and direct hidden script use are tested.", + "Real packaged/hidden publication and API batch validation preserve source/mapping/destination bytes on refusal.", + "The actual unchanged default table still lacks Dataset.id and refuses publication." + ], + "limitations": [ + "No five-project or retained-five-input replay was performed for this DOI slice.", + "Other legacy mapping/construction defects remain under #4594; this is not overall legacy producer acceptance.", + "No new Dataset ID, source-coverage gain, scientific fidelity/score, provider generation or empirical campaign is claimed.", + "No historical records, mapping TSVs or scientific labels were rewritten; row retirement is not source coverage and copied text does not establish exactMatch/none.", + "The existing repair parser trims surrounding whitespace and trailing slashes on prefixed forms; already-bare strings are preserved exactly.", + "Different nonmissing property spellings, even semantically equivalent DOI spellings, are deliberately refused rather than collapsed." + ], + "scientific_eligibility": false +} From 1a98255bb28451500d4864876a16d9c6c2f6b435 Mon Sep 17 00:00:00 2001 From: "marcin p. joachimiak" <4625870+realmarcin@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:03:32 -0700 Subject: [PATCH 3/5] fix: preserve complete legacy DOI suffixes (refs #4671) --- notes/legacy_doi_2026-10-08/README.md | 44 +++++++++++++++++++-------- src/data_sheets_schema/legacy_doi.py | 14 +++++++-- tests/test_rocrate/test_legacy_doi.py | 32 ++++++++++++++++++- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/notes/legacy_doi_2026-10-08/README.md b/notes/legacy_doi_2026-10-08/README.md index d93b5a8ae..514d49e8c 100644 --- a/notes/legacy_doi_2026-10-08/README.md +++ b/notes/legacy_doi_2026-10-08/README.md @@ -9,12 +9,14 @@ became `https://doi.org/10.1234/MixedCase`, contradicting the Dataset schema's bare-DOI requirement. Generic URI targets keep their existing behavior. Recognized scalar `doi:`, HTTP(S) `doi.org`, and HTTP(S) `dx.doi.org` forms use -the existing `scope.bare_doi` repair parser. Prefix matching is case insensitive; -suffix case is retained. That parser removes surrounding whitespace and trailing -slashes from a prefixed value. Strings already starting with `10.` stay exactly -as supplied, including schema-valid bare forms outside the repair parser's -narrower grammar. The mandatory final Dataset validator still decides whether -a retained value is valid. +the existing `scope.bare_doi` parser only to recognize an eligible repair. +Prefix matching is case insensitive; the entire suffix, including its case and +one or more trailing slashes, is taken from the source string. After the existing +outer-whitespace trim, only the known representation prefix is removed. The +parser's slash-stripped return value is never used as the constructed DOI. +Strings already starting with `10.` stay exactly as supplied, including +schema-valid bare forms outside the repair parser's narrower grammar. The +mandatory final Dataset validator still decides whether a retained value is valid. Malformed strings, objects, booleans, numbers and lists stay intact in drafts. A singleton, repeated, mixed or conflicting list never contributes just its @@ -41,7 +43,20 @@ Neither source coverage nor semantic equivalence follows from this correction; row retirement is not a coverage gain, and intact text does not establish `exactMatch`/`none`. -## Validation results +## Pending suffix-preservation correction + +The second review round found [#4671](https://github.com/bridge2ai/data-sheets-schema/issues/4671) +before publication: the first implementation used the recognizer's return value, +which silently removed trailing slashes from prefixed DOI suffixes. The earlier +suite covered this ending only for bare inputs. The correction now removes only +the known prefix and adds both-builder controls for all accepted prefix forms, +mixed case, internal slashes and one/multiple trailing slashes, through actual +Dataset validation and file publication. Unrecognized forms remain intact for +refusal. Production code and tests changed; independent review and a fresh +focused run are pending. The 318-pass evidence below belongs to the earlier +implementation and does not validate this correction. + +## Earlier validation results The serialized eight-module run passed **318 tests**, with zero failures, errors or skips, at commit @@ -61,21 +76,24 @@ seconds; its XML does not encode the warning count or attest the tested commit. | `tests/test_cli/test_rocrate_transform_mapping.py` | 17 | | `tests/test_cli/test_rocrate_transform_mapping_adversarial.py` | 10 | -The 117 new cases exercise both real builders and the current closed Dataset +The earlier 117 new cases exercised both real builders and the current closed Dataset validator, packaged/hidden publishers, the real API batch path with an explicit valid-ID mapping, and direct hidden-script use from another working directory. -They cover the accepted forms and preservation/refusal boundaries above, +They covered the then-tested accepted forms and preservation/refusal boundaries, including a later invalid batch DOI preventing every destination replacement. Existing publication, result-contract and wrapper controls also passed. -Independent adversarial source review found no material issues before execution. +Initial independent source review found no issues before that execution; the +later review found the missing prefixed-suffix case documented above. [validation.json](validation.json) records the command with symbolic paths, the eight module counts, and 26 selected production/test/resource/dependency -pins. Each selected working file was compared byte-for-byte with the tested Git -blob. These pins cover the named files, not a complete installed environment. +pins for that earlier commit. Each selected working file then matched the tested +Git blob; the current correction intentionally changes the DOI helper and tests. +These pins cover the named files, not a complete installed environment. The retained external JUnit artifact `legacy-doi-tests-01.xml` is 51,571 bytes, SHA-256 `30c50542813c258f877d4631d55341ab69cbe564b0afb2de163465a4d6ce3905`. -Only these evidence notes changed after the run; source and tests are unchanged. +The earlier artifact and its source pins are retained without relabeling them +as evidence for the pending correction. No real-project replay, provider call, scientific rating or new historical label was produced. All five retained-input legacy replays and the remaining default diff --git a/src/data_sheets_schema/legacy_doi.py b/src/data_sheets_schema/legacy_doi.py index 8f9a0df3b..75eb92038 100644 --- a/src/data_sheets_schema/legacy_doi.py +++ b/src/data_sheets_schema/legacy_doi.py @@ -1,9 +1,13 @@ """DOI-only construction for the legacy TSV builders; never a validation gate.""" from copy import deepcopy +import re from data_sheets_schema.scope import bare_doi +_PREFIX = re.compile(r'^(?:doi:|https?://(?:dx\.)?doi\.org/)', re.IGNORECASE) + + def doi_value(value): """Repair recognised scalar prefixes without discarding source assertions. @@ -13,9 +17,13 @@ def doi_value(value): particular, a list never contributes only its first or recognisable DOI. """ if isinstance(value, str) and not value.startswith('10.'): - repaired = bare_doi(value) - if repaired is not None: - return repaired + source = value.strip() + prefix = _PREFIX.match(source) + if prefix is not None and bare_doi(source) is not None: + # The recognizer strips trailing '/' for its comparison policy. + # It admits the repair, but only the representation prefix may be + # removed here: every suffix character remains source evidence. + return source[prefix.end():] return deepcopy(value) diff --git a/tests/test_rocrate/test_legacy_doi.py b/tests/test_rocrate/test_legacy_doi.py index 276b855bc..aa5cbf8f1 100644 --- a/tests/test_rocrate/test_legacy_doi.py +++ b/tests/test_rocrate/test_legacy_doi.py @@ -90,9 +90,39 @@ def test_schema_valid_bare_values_are_not_narrowed_or_trimmed(implementation, tm assert yaml.safe_load(publication.prepare_dataset(record))['doi'] == value +@pytest.mark.parametrize('prefix', ['doi:', 'DOI:', 'http://doi.org/', + 'https://doi.org/', 'http://dx.doi.org/', 'https://dx.doi.org/', 'HTTPS://DOI.ORG/']) +@pytest.mark.parametrize('suffix', ['Record/', 'MiXeD/Part///']) +def test_prefixed_suffix_slashes_survive_real_construction_and_publication( + implementation, tmp_path, prefix, suffix): + """#4671: recognition must not delete the source DOI's suffix ending.""" + expected = '10.1234/' + suffix + source, table = crate(tmp_path, prefix + expected), mapping(tmp_path) + before = {path: path.read_bytes() for path in (source, table)} + record = build(implementation, source, table) + assert record['doi'] == expected + raw = publication.prepare_dataset(record) + output = tmp_path / 'record.yaml' + publication.publish([(output, raw)], protected=[source, table]) + assert output.read_bytes() == raw + assert yaml.safe_load(output.read_bytes())['doi'] == expected + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize('prefix', ['DOI:', 'https://dx.doi.org/']) +def test_outer_whitespace_does_not_remove_suffix_slashes(implementation, tmp_path, prefix): + expected = '10.1234/MiXeD/Part///' + record = build(implementation, crate(tmp_path, ' \t' + prefix + expected + '\t\n'), + mapping(tmp_path)) + assert record['doi'] == expected + assert yaml.safe_load(publication.prepare_dataset(record))['doi'] == expected + + INVALID = ['', '10.', '10.123/x', 'not a DOI', 'ark:12345/record', 'https://example.org/?persistentId=doi:10.1234/Example', - 'https://doi.org/10.1234/two words', {'@id': 'doi:' + BARE}, False, 0, + 'https://doi.org/10.1234/two words', 'doi:10.1234///', + 'https://example.org/10.1234/Record///', ' 10.1234/NoPrefix/ ', + {'@id': 'doi:' + BARE}, False, 0, [], [BARE], [BARE, BARE], [BARE, '10.5678/Conflicting'], [BARE, None], ['not DOI', 'doi:' + BARE]] From 180d1028bb25332ff296eafcbfb4533d3f6ee85d Mon Sep 17 00:00:00 2001 From: "marcin p. joachimiak" <4625870+realmarcin@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:21:52 -0700 Subject: [PATCH 4/5] fix: construct explicit legacy root identity without coverage gains Refs #4672 and #4674. Preserve scalar source identity, primary selection, and custom mapping behavior. --- .claude/agents/scripts/d4d_builder.py | 10 +- .../agents/scripts/informativeness_scorer.py | 12 +- .claude/agents/scripts/mapping_loader.py | 18 +- .claude/agents/scripts/rocrate_merger.py | 41 ++- .claude/agents/scripts/rocrate_to_d4d.py | 24 +- .../d4d_rocrate_mapping_v2_semantic.tsv | 1 + .../legacy_root_identity_2026-10-08/README.md | 63 ++++ .../legacy_root_identity.py | 93 ++++++ src/fairscape_integration/cli.py | 15 +- .../utils/d4d_builder.py | 10 +- .../utils/informativeness_scorer.py | 6 +- .../utils/mapping_loader.py | 12 +- .../utils/rocrate_merger.py | 35 +- src/transformation/transform_api.py | 5 +- tests/test_rocrate/test_legacy_doi.py | 7 +- tests/test_rocrate/test_legacy_root_gates.py | 8 +- .../test_rocrate/test_legacy_root_identity.py | 306 ++++++++++++++++++ .../test_legacy_root_identity_adversarial.py | 244 ++++++++++++++ 18 files changed, 869 insertions(+), 41 deletions(-) create mode 100644 notes/legacy_root_identity_2026-10-08/README.md create mode 100644 src/data_sheets_schema/legacy_root_identity.py create mode 100644 tests/test_rocrate/test_legacy_root_identity.py create mode 100644 tests/test_rocrate/test_legacy_root_identity_adversarial.py diff --git a/.claude/agents/scripts/d4d_builder.py b/.claude/agents/scripts/d4d_builder.py index eebb37749..4f7321722 100644 --- a/.claude/agents/scripts/d4d_builder.py +++ b/.claude/agents/scripts/d4d_builder.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(_source)) from data_sheets_schema.legacy_doi import doi_value, source_value +from data_sheets_schema.legacy_root_identity import root_identity_route, resolve_root_identity class D4DBuilder: @@ -43,7 +44,9 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: Dict with D4D Dataset structure """ # Use the same production gate as the packaged builder (#4588). - rocrate_parser.require_root_dataset() + root = rocrate_parser.require_root_dataset() + marked_id = root_identity_route(self.mapping) + identity = resolve_root_identity(root) if marked_id else None self.d4d_data = {} # Get all covered D4D fields @@ -54,6 +57,11 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: # Map each covered field mapped_count = 0 for d4d_field in covered_fields: + if marked_id and d4d_field == 'id': + if identity['id'] is not None: + self.d4d_data['id'] = identity['id'] + mapped_count += 1 + continue rocrate_property = self.mapping.get_rocrate_property(d4d_field) if not rocrate_property: continue diff --git a/.claude/agents/scripts/informativeness_scorer.py b/.claude/agents/scripts/informativeness_scorer.py index d6f6f9e3a..40a0d924d 100644 --- a/.claude/agents/scripts/informativeness_scorer.py +++ b/.claude/agents/scripts/informativeness_scorer.py @@ -9,6 +9,14 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +import sys + +_source = Path(__file__).resolve().parents[3] / 'src' +if (_source / 'data_sheets_schema/legacy_root_identity.py').is_file() and str(_source) not in sys.path: + sys.path.insert(0, str(_source)) + +from data_sheets_schema.legacy_root_identity import scoring_fields + class InformativenessScorer: """Score and rank RO-Crate sources by D4D informativeness.""" @@ -51,7 +59,7 @@ def score_rocrate( scores = {} # 1. D4D Coverage - how many fields can this RO-Crate populate? - covered_fields = mapping_loader.get_covered_fields() + covered_fields = scoring_fields(mapping_loader) populated_count = 0 for d4d_field in covered_fields: @@ -144,7 +152,7 @@ def _count_unique_fields( other_parsers: List ) -> int: """Count fields unique to this RO-Crate vs others.""" - covered_fields = mapping_loader.get_covered_fields() + covered_fields = scoring_fields(mapping_loader) unique_count = 0 for d4d_field in covered_fields: diff --git a/.claude/agents/scripts/mapping_loader.py b/.claude/agents/scripts/mapping_loader.py index bd7c4d589..c73576ded 100644 --- a/.claude/agents/scripts/mapping_loader.py +++ b/.claude/agents/scripts/mapping_loader.py @@ -10,6 +10,14 @@ from pathlib import Path from typing import Dict, List, Optional, Set +import sys + +_source = Path(__file__).resolve().parents[3] / 'src' +if (_source / 'data_sheets_schema/legacy_root_identity.py').is_file() and str(_source) not in sys.path: + sys.path.insert(0, str(_source)) + +from data_sheets_schema.legacy_root_identity import MARKER, validate_rows + class MappingLoader: """Load and manage D4D to RO-Crate field mappings from TSV file.""" @@ -36,9 +44,10 @@ def __init__(self, tsv_path: str): def _load_mappings(self): """Load and parse the TSV mapping file.""" with open(self.tsv_path, 'r', encoding='utf-8') as f: - reader = csv.DictReader(f, delimiter='\t') + rows = list(csv.DictReader(f, delimiter='\t')) + validate_rows(rows) - for row in reader: + for row in rows: # Skip header rows and empty rows if not row.get('D4D Property') or row['D4D Property'].startswith('D4D:'): continue @@ -61,7 +70,10 @@ def _load_mappings(self): for rc_prop in rocrate_field.split(','): rc_prop = rc_prop.strip() if rc_prop: - self.rocrate_to_d4d[rc_prop] = d4d_field + if row.get('Func', '').strip() == MARKER: + self.rocrate_to_d4d.setdefault(rc_prop, d4d_field) + else: + self.rocrate_to_d4d[rc_prop] = d4d_field # Track direct mappings (1:1 relationships) direct = row.get('Direct mapping? Yes =1; No = 0', '0').strip() diff --git a/.claude/agents/scripts/rocrate_merger.py b/.claude/agents/scripts/rocrate_merger.py index ce8da477b..5978e951b 100644 --- a/.claude/agents/scripts/rocrate_merger.py +++ b/.claude/agents/scripts/rocrate_merger.py @@ -13,6 +13,10 @@ from field_prioritizer import FieldPrioritizer, MergeStrategy from d4d_builder import D4DBuilder +from data_sheets_schema.legacy_root_identity import ( + root_identity_route, merge_identity_evidence, identity_report_lines, +) + class ROCrateMerger: """Merge multiple RO-Crate sources into single D4D dataset.""" @@ -25,6 +29,7 @@ def __init__(self, mapping_loader): mapping_loader: MappingLoader instance with field mappings """ self.mapping = mapping_loader + self.root_identity_sources = None self.primary_index = 0 self.primary_name = "" self.prioritizer = FieldPrioritizer() @@ -66,6 +71,24 @@ def merge_rocrates( for parser in rocrate_parsers: parser.require_root_dataset() + marked_id = root_identity_route(self.mapping) + identity_sources = None + if marked_id: + if source_names is None: + source_names = [Path(parser.rocrate_path).name.replace( + '-ro-crate-metadata.json', '') for parser in rocrate_parsers] + if len(source_names) != len(rocrate_parsers): + raise ValueError('source_names must match the selected RO-Crates') + identity_sources = merge_identity_evidence( + rocrate_parsers, source_names, primary_index) + if marked_id or self.root_identity_sources is not None: + # A new marked merge (or returning to a custom mapping) must not + # inherit an earlier Dataset.id when its primary has no identity. + self.merged_data = {} + self.provenance = {} + self.merge_stats = {key: 0 for key in self.merge_stats} + self.root_identity_sources = identity_sources + self.merge_stats['total_sources'] = len(rocrate_parsers) # Get source names @@ -114,12 +137,16 @@ def merge_rocrates( ] # Merge this field - merged_value, sources = self.merge_field( - field_name, - primary_value, - secondary_values, - primary_name - ) + if marked_id and field_name == 'id': + merged_value = primary_value + sources = [primary_name] if primary_value is not None else [] + else: + merged_value, sources = self.merge_field( + field_name, + primary_value, + secondary_values, + primary_name + ) if merged_value is not None: self.merged_data[field_name] = merged_value @@ -247,6 +274,8 @@ def generate_merge_report( report.append(f" - D4D fields contributed: {contributed_fields}") report.append("") + report.extend(identity_report_lines(self.root_identity_sources)) + # Merge statistics report.append("MERGE STATISTICS") report.append("-"*80) diff --git a/.claude/agents/scripts/rocrate_to_d4d.py b/.claude/agents/scripts/rocrate_to_d4d.py index 5125c7f4b..148554603 100644 --- a/.claude/agents/scripts/rocrate_to_d4d.py +++ b/.claude/agents/scripts/rocrate_to_d4d.py @@ -43,6 +43,7 @@ from rocrate_merger import ROCrateMerger from informativeness_scorer import InformativenessScorer from data_sheets_schema.legacy_publication import prepare_dataset, publish, diagnostic +from data_sheets_schema.legacy_root_identity import root_identity_route, scoring_fields def render_transformation_report( @@ -58,15 +59,20 @@ def render_transformation_report( f.write("="*80 + "\n\n") # Transformation summary - covered_fields = mapping_loader.get_covered_fields() + covered_fields = scoring_fields(mapping_loader) dataset = d4d_builder.get_dataset() + identity_construction = root_identity_route(mapping_loader) + populated_count = len(dataset) - int(identity_construction and 'id' in dataset) + percentage = populated_count / len(covered_fields) * 100 if covered_fields else 0 f.write("TRANSFORMATION SUMMARY\n") f.write("-"*80 + "\n") f.write(f"Total D4D fields in mapping: {len(covered_fields)}\n") - f.write(f"Fields populated from RO-Crate: {len(dataset)}\n") - f.write(f"Coverage: {len(dataset)}/{len(covered_fields)} ") - f.write(f"({len(dataset)/len(covered_fields)*100:.1f}%)\n\n") + f.write(f"Fields populated from RO-Crate: {populated_count}\n") + f.write(f"Coverage: {populated_count}/{len(covered_fields)} ") + f.write(f"({percentage:.1f}%)\n\n") + if identity_construction: + f.write("Required Dataset.id construction is excluded from coverage.\n\n") # Unmapped RO-Crate properties mapped_props = mapping_loader.get_all_mapped_rocrate_properties() @@ -456,8 +462,14 @@ def main(): print(f"Output: {output_path}") print(f"\nFields populated: {len(dataset)}") - print(f"Coverage: {len(dataset)}/{len(mapping.get_covered_fields())} mapped fields") - print(f"Percentage: {len(dataset)/len(mapping.get_covered_fields())*100:.1f}%") + covered_fields = scoring_fields(mapping) + identity_construction = root_identity_route(mapping) + populated_count = len(dataset) - int(identity_construction and 'id' in dataset) + percentage = populated_count / len(covered_fields) * 100 if covered_fields else 0 + print(f"Coverage: {populated_count}/{len(covered_fields)} mapped fields") + print(f"Percentage: {percentage:.1f}%") + if identity_construction: + print("Required Dataset.id construction is excluded from coverage.") return 0 diff --git a/data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv b/data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv index 6c045b648..2c214d61e 100644 --- a/data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv +++ b/data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv @@ -82,3 +82,4 @@ RO-Crate: Fairscape Release RO-Crate acquisition_methods (slot exists but no d version_access Will older versions of the dataset continue to be supported/hosted/maintained? If so, how? If not, how will obsolescence be communicated to dataset consumers? version 1 0 0 relatedMatch http://www.w3.org/2004/02/skos/core#relatedMatch minimal version_access """All versions available""" """All versions available""" vulnerable_populations Information about protections for vulnerable populations in human subjects research. atRiskPopulations 1 1 0 exactMatch http://www.w3.org/2004/02/skos/core#exactMatch none vulnerable_populations """Children excluded""" """Children excluded""" was_derived_from str (slot exists but no description) generatedBy 1 1 0 exactMatch http://www.w3.org/2004/02/skos/core#exactMatch none was_derived_from xsd:string constraint """10.5281/zenodo.111""" """10.5281/zenodo.111""" +D4D: Dataset id URI Required identity from the selected crate root. identifier,@id root_identifier_v1 Scalar identifier, else root @id; preserve the complete DOI suffix; no minted ID or list selection. Construction only, excluded from informativeness scoring. Semantic fidelity unassessed. 1 0 0 doi:10.1234/Example/ https://doi.org/10.1234/Example/ diff --git a/notes/legacy_root_identity_2026-10-08/README.md b/notes/legacy_root_identity_2026-10-08/README.md new file mode 100644 index 000000000..1b85437a5 --- /dev/null +++ b/notes/legacy_root_identity_2026-10-08/README.md @@ -0,0 +1,63 @@ +# Explicit legacy root identity (#4672) + +The default legacy TSV now includes a required `Dataset.id` construction route, +selected by `Func=root_identifier_v1` on one covered `id` row with source +`identifier,@id`. Both legacy implementations use the already selected crate +root. A nonblank scalar `identifier` wins; absent, null or blank text falls back +to root `@id`. Present lists, objects, numbers and booleans refuse before changing +the prior builder or merger result. No identity is invented or borrowed from a +member dataset. + +Recognized whole scalar DOIs are written as `doi:` CURIEs. The complete body, +including case and every trailing slash, survives. Other text and ARKs retain +their exact spelling; the ID route does not trim surrounding whitespace. The +separate `Dataset.doi` implementation is unchanged from the reviewed predecessor +`1a98255bb28451500d4864876a16d9c6c2f6b435`, including its suffix-preservation fix +for #4671. + +The marker is an explicit selector, never executable TSV code. Unmarked custom +ID and no-ID tables keep their prior construction, reverse lookup and scoring +behavior. Misplaced, uncovered, duplicate or incompatible marked declarations +refuse at table loading, including rows otherwise skipped as headers. The +ordinary `identifier -> doi` reverse mapping wins in either row order. + +Merged ID comes only from the configured primary source. Every marked input's +identity is checked before merge state changes; a secondary identity cannot fill +a missing primary identity. The existing merge report records the selected +primary, source properties, original input assertions and differing written IDs. +Those differences do not assert that sources describe the same entity. Required +identity construction is excluded from the scorer's coverage and uniqueness +denominators and from API and CLI source-coverage statistics. Existing ranking, +ties and automatic primary selection therefore retain their prior basis. +Review issue #4674 extended that exclusion to the API result metadata and both +CLI report/stdout consumers; their construction inventories remain separate. + +All original default TSV rows are retained. The new row leaves its semantic +mapping, SKOS and information-loss claims unassessed. Successful construction or +literal preservation does not establish `exactMatch`, `none`, scientific source +coverage, or a valid complete Dataset. Missing required identity and remaining +Class/nested-creator/type errors still reach the mandatory publication gate; +legacy API metadata still requires the explicit `dataset_v1` result contract for +Dataset-only publication. Neither another mapper nor a generated identifier is +substituted on failure. + +## Validation status + +Implementation and focused tests are prepared for independent static review. +No tests, five-input replay, provider calls or real-record publication have been +run for this change yet. Tests exercise both real loaders, constructors, scorers +and mergers; complete DOI suffixes, graph reordering, fallback and refusal; +custom-table compatibility; merge preflight and primary-only identity; mandatory +publication and existing output preservation. Independent controls exercise a +real ranking reversal when an unmarked construction row is incorrectly counted, +and source-coverage reporting across result contracts. + +The separately scheduled five-input comparison must retain the CHORUS, VOICE, +CM4AI reduced, CM4AI original archive member, and VOICE provenance evidence. It +must compare against the DOI-fixed predecessor and record all remaining +validation errors, with source preservation and unchanged ranking. Existing +historical records and the 43-row accepted-policy snapshot remain unchanged. + +Parent issues #4594 and #2915 remain open for the broader construction, +validation, comparison and publication obligations. This repair does not resolve +human scientific review, applicability, routing or paid-execution holds. diff --git a/src/data_sheets_schema/legacy_root_identity.py b/src/data_sheets_schema/legacy_root_identity.py new file mode 100644 index 000000000..44321dd0f --- /dev/null +++ b/src/data_sheets_schema/legacy_root_identity.py @@ -0,0 +1,93 @@ +"""Explicit scalar root identity for legacy mappings, never an inferred ID.""" +from copy import deepcopy +import json + +from data_sheets_schema.legacy_doi import doi_value +from data_sheets_schema.scope import bare_doi + + +MARKER = 'root_identifier_v1' +COVERED = 'Covered by FAIRSCAPE? Yes =1; No = 0' + + +def validate_rows(rows): + """Admit one explicit route; leave all unmarked custom mappings alone.""" + marked = [row for row in rows if (row.get('Func') or '').strip() == MARKER] + if not marked: + return False + ids = [row for row in rows if (row.get('D4D Property') or '').strip() == 'id' + and (row.get(COVERED) or '').strip() == '1'] + if (len(marked) != 1 or len(ids) != 1 or marked[0] is not ids[0] + or [part.strip() for part in (marked[0].get( + 'FAIRSCAPE RO-Crate Property') or '').split(',')] != ['identifier', '@id']): + raise ValueError(f'{MARKER} requires one covered id row with identifier,@id; ' + 'duplicate or conflicting ID declarations are not supported') + return True + + +def root_identity_route(mapping): + return validate_rows(getattr(mapping, 'mappings', ())) + + +def scoring_fields(mapping): + """A construction-only marked ID must not change the existing ranking.""" + marked = root_identity_route(mapping) + return [field for field in mapping.get_covered_fields() + if not (marked and field == 'id')] + + +def _written_id(value): + # The DOI helper removes only a recognised prefix and preserves the complete + # suffix (#4671). Our stricter route does not trim outer whitespace either. + if value == value.strip(): + body = doi_value(value) + if body.startswith('10.') and bare_doi(body) is not None: + return 'doi:' + body + return value + + +def resolve_root_identity(root): + """Read the selected root only, retaining absence and original assertions.""" + evidence = {'id': None, 'source_property': None, 'source_value': None, + 'inputs': {key: {'present': key in root, 'value': deepcopy(root.get(key))} + for key in ('identifier', '@id')}} + for key in ('identifier', '@id'): + value = root.get(key) + if value is None: + continue + if not isinstance(value, str): + raise ValueError(f'{MARKER}: root {key} must be scalar text; ' + 'no list item or non-text identifier was selected') + if not value.strip(): + continue + evidence.update(id=_written_id(value), source_property=key, source_value=value) + break + return evidence + + +def merge_identity_evidence(parsers, source_names, primary_index): + """Preflight every root before changing merge state; no identity union.""" + primary_index = range(len(parsers))[primary_index] + rows = [] + for index, (parser, name) in enumerate(zip(parsers, source_names)): + try: + evidence = resolve_root_identity(parser.require_root_dataset()) + except ValueError as exc: + raise ValueError(f'{parser.rocrate_path}: {exc}') from exc + rows.append({'source': name, 'path': str(parser.rocrate_path), + 'selected_primary': index == primary_index, **evidence}) + primary_id = rows[primary_index]['id'] + for row in rows: + row['different_written_id'] = (row['id'] != primary_id) + return rows + + +def identity_report_lines(rows): + if rows is None: + return [] + return ['', 'ROOT IDENTITY SELECTION', '-' * 80, + 'Dataset.id uses only the configured primary source. ' + 'Different written IDs do not assert entity equivalence.', + 'Required identity construction is excluded from informativeness scoring; ' + 'it is not a source-coverage gain.', + *(json.dumps(row, ensure_ascii=False, sort_keys=True) for row in rows), ''] diff --git a/src/fairscape_integration/cli.py b/src/fairscape_integration/cli.py index 214ef27ea..4f3c98d64 100644 --- a/src/fairscape_integration/cli.py +++ b/src/fairscape_integration/cli.py @@ -25,6 +25,7 @@ from fairscape_integration.utils.d4d_builder import D4DBuilder from fairscape_integration.utils.rocrate_merger import ROCrateMerger from fairscape_integration.utils.informativeness_scorer import InformativenessScorer +from data_sheets_schema.legacy_root_identity import root_identity_route, scoring_fields @click.group() @@ -510,8 +511,10 @@ def transform(input_file, output, mapping, report, validate): raw = prepare_dataset(dataset, default_flow_style=False, sort_keys=False, context=f"Input {input_file}; output {output_path}") prepared = [(output_path, raw)] - covered_fields = mapping_loader.get_covered_fields() - coverage = len(dataset) / len(covered_fields) * 100 if covered_fields else 0 + covered_fields = scoring_fields(mapping_loader) + identity_construction = root_identity_route(mapping_loader) + populated_count = len(dataset) - int(identity_construction and 'id' in dataset) + coverage = populated_count / len(covered_fields) * 100 if covered_fields else 0 if report: report_path = output_path.parent / f"{output_path.stem}_report.txt" f = StringIO() @@ -520,8 +523,10 @@ def transform(input_file, output, mapping, report, validate): f.write("="*80 + "\n\n") f.write("SUMMARY\n" + "-"*80 + "\n") f.write(f"Input: {input_file}\nOutput: {output}\nMapping: {mapping}\n") - f.write(f"Fields populated: {len(dataset)}/{len(covered_fields)}\n") + f.write(f"Fields populated: {populated_count}/{len(covered_fields)}\n") f.write(f"Coverage: {coverage:.1f}%\n\n") + if identity_construction: + f.write("Required Dataset.id construction is excluded from coverage.\n\n") mapped_props = mapping_loader.get_all_mapped_rocrate_properties() unmapped = parser.get_unmapped_properties(mapped_props) f.write("UNMAPPED RO-CRATE PROPERTIES\n" + "-"*80 + "\n") @@ -536,8 +541,10 @@ def transform(input_file, output, mapping, report, validate): diagnostic(raw, validator.validate_d4d_yaml) publish(prepared, protected=[input_file, mapping]) click.echo(f"✓ D4D YAML written to: {output}") - click.echo(f" Fields populated: {len(dataset)}/{len(covered_fields)}") + click.echo(f" Fields populated: {populated_count}/{len(covered_fields)}") click.echo(f" Coverage: {coverage:.1f}%") + if identity_construction: + click.echo(" Required Dataset.id construction is excluded from coverage.") if report: click.echo(f"✓ Transformation report saved: {report_path}") if validate: diff --git a/src/fairscape_integration/utils/d4d_builder.py b/src/fairscape_integration/utils/d4d_builder.py index 016f21f54..1eb53982d 100644 --- a/src/fairscape_integration/utils/d4d_builder.py +++ b/src/fairscape_integration/utils/d4d_builder.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Union from data_sheets_schema.legacy_doi import doi_value, source_value +from data_sheets_schema.legacy_root_identity import root_identity_route, resolve_root_identity class D4DBuilder: @@ -39,7 +40,9 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: """ # Inspection may have no root; producing a Dataset may not. Refuse # before clearing a previous result or mapping any member properties. - rocrate_parser.require_root_dataset() + root = rocrate_parser.require_root_dataset() + marked_id = root_identity_route(self.mapping) + identity = resolve_root_identity(root) if marked_id else None self.d4d_data = {} # Get all covered D4D fields @@ -51,6 +54,11 @@ def build_dataset(self, rocrate_parser) -> Dict[str, Any]: # Map each covered field mapped_count = 0 for d4d_field in covered_fields: + if marked_id and d4d_field == 'id': + if identity['id'] is not None: + self.d4d_data['id'] = identity['id'] + mapped_count += 1 + continue rocrate_property = self.mapping.get_rocrate_property(d4d_field) if not rocrate_property: continue diff --git a/src/fairscape_integration/utils/informativeness_scorer.py b/src/fairscape_integration/utils/informativeness_scorer.py index 5ef870aa2..06e23f39e 100644 --- a/src/fairscape_integration/utils/informativeness_scorer.py +++ b/src/fairscape_integration/utils/informativeness_scorer.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from data_sheets_schema.legacy_root_identity import scoring_fields + class InformativenessScorer: """Score and rank RO-Crate sources by D4D informativeness.""" @@ -58,7 +60,7 @@ def score_rocrate( scores = {} # 1. D4D Coverage - how many fields can this RO-Crate populate? - covered_fields = mapping_loader.get_covered_fields() + covered_fields = scoring_fields(mapping_loader) populated_count = 0 for d4d_field in covered_fields: @@ -162,7 +164,7 @@ def _count_unique_fields( other_parsers: List ) -> int: """Count fields unique to this RO-Crate vs others.""" - covered_fields = mapping_loader.get_covered_fields() + covered_fields = scoring_fields(mapping_loader) unique_count = 0 for d4d_field in covered_fields: diff --git a/src/fairscape_integration/utils/mapping_loader.py b/src/fairscape_integration/utils/mapping_loader.py index bf3d58dc6..b45a3bafb 100644 --- a/src/fairscape_integration/utils/mapping_loader.py +++ b/src/fairscape_integration/utils/mapping_loader.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Dict, List, Optional, Set +from data_sheets_schema.legacy_root_identity import MARKER, validate_rows + class MappingLoader: """Load and manage D4D to RO-Crate field mappings from TSV file.""" @@ -39,9 +41,10 @@ def __init__(self, tsv_path: str, verbose: bool = False): def _load_mappings(self): """Load and parse the TSV mapping file.""" with open(self.tsv_path, 'r', encoding='utf-8') as f: - reader = csv.DictReader(f, delimiter='\t') + rows = list(csv.DictReader(f, delimiter='\t')) + validate_rows(rows) - for row in reader: + for row in rows: # Skip header rows and empty rows if not row.get('D4D Property') or row['D4D Property'].startswith('D4D:'): continue @@ -64,7 +67,10 @@ def _load_mappings(self): for rc_prop in rocrate_field.split(','): rc_prop = rc_prop.strip() if rc_prop: - self.rocrate_to_d4d[rc_prop] = d4d_field + if row.get('Func', '').strip() == MARKER: + self.rocrate_to_d4d.setdefault(rc_prop, d4d_field) + else: + self.rocrate_to_d4d[rc_prop] = d4d_field # Track direct mappings (1:1 relationships) direct = row.get('Direct mapping? Yes =1; No = 0', '0').strip() diff --git a/src/fairscape_integration/utils/rocrate_merger.py b/src/fairscape_integration/utils/rocrate_merger.py index f11eec7ff..0415195da 100644 --- a/src/fairscape_integration/utils/rocrate_merger.py +++ b/src/fairscape_integration/utils/rocrate_merger.py @@ -14,6 +14,10 @@ from .field_prioritizer import FieldPrioritizer from .d4d_builder import D4DBuilder +from data_sheets_schema.legacy_root_identity import ( + root_identity_route, merge_identity_evidence, identity_report_lines, +) + class ROCrateMerger: """Merge multiple RO-Crate sources into single D4D dataset.""" @@ -27,6 +31,7 @@ def __init__(self, mapping_loader, verbose: bool = False): verbose: Print merge progress """ self.mapping = mapping_loader + self.root_identity_sources = None self.prioritizer = FieldPrioritizer() self.merged_data: Dict[str, Any] = {} self.provenance: Dict[str, List[str]] = {} @@ -70,6 +75,18 @@ def merge_rocrates( for parser in rocrate_parsers: parser.require_root_dataset() + marked_id = root_identity_route(self.mapping) + identity_sources = None + if marked_id: + if source_names is None: + source_names = [Path(parser.rocrate_path).name.replace( + '-ro-crate-metadata.json', '') for parser in rocrate_parsers] + if len(source_names) != len(rocrate_parsers): + raise ValueError('source_names must match the selected RO-Crates') + identity_sources = merge_identity_evidence( + rocrate_parsers, source_names, primary_index) + self.root_identity_sources = identity_sources + # Reset state so the same instance can be reused for multiple merges self.merged_data = {} self.provenance = {} @@ -142,12 +159,16 @@ def merge_rocrates( ] # Merge this field - merged_value, sources = self.merge_field( - field_name, - primary_value, - secondary_values, - primary_name - ) + if marked_id and field_name == 'id': + merged_value = primary_value + sources = [primary_name] if primary_value is not None else [] + else: + merged_value, sources = self.merge_field( + field_name, + primary_value, + secondary_values, + primary_name + ) if merged_value is not None: self.merged_data[field_name] = merged_value @@ -279,6 +300,8 @@ def generate_merge_report( report.append(f" - D4D fields contributed: {contributed_fields}") report.append("") + report.extend(identity_report_lines(self.root_identity_sources)) + # Merge statistics report.append("MERGE STATISTICS") report.append("-"*80) diff --git a/src/transformation/transform_api.py b/src/transformation/transform_api.py index 51d3b66ff..94b615817 100644 --- a/src/transformation/transform_api.py +++ b/src/transformation/transform_api.py @@ -72,6 +72,7 @@ SCRIPTS_AVAILABLE = False from data_sheets_schema.legacy_publication import PublicationError, prepare_dataset, publish +from data_sheets_schema.legacy_root_identity import scoring_fields RESULT_CONTRACTS = frozenset({"legacy", "dataset_v1"}) RESULT_FORMAT = "d4d_transformation_result_v1" @@ -325,7 +326,9 @@ def rocrate_to_d4d( d4d_dict = builder.build_dataset(parser) # Track coverage statistics - covered_fields = self.mapping_loader.get_covered_fields() + # Required root identity is construction, not additional source + # coverage. Unmarked custom ID mappings keep their prior meaning. + covered_fields = scoring_fields(self.mapping_loader) mapped_count = len([f for f in covered_fields if d4d_dict.get(f) is not None]) coverage_percentage = (mapped_count / len(covered_fields) * 100) if covered_fields else 0.0 unmapped_fields = [f for f in covered_fields if d4d_dict.get(f) is None] diff --git a/tests/test_rocrate/test_legacy_doi.py b/tests/test_rocrate/test_legacy_doi.py index aa5cbf8f1..c31efeec1 100644 --- a/tests/test_rocrate/test_legacy_doi.py +++ b/tests/test_rocrate/test_legacy_doi.py @@ -214,13 +214,12 @@ def test_real_publishers_preserve_destinations_on_unaccepted_doi( assert {p: p.read_bytes() for p in before} == before -def test_unchanged_actual_default_table_still_requires_root_id(implementation, tmp_path): +def test_actual_default_table_constructs_root_id_without_changing_bare_doi(implementation, tmp_path): table = REPO / 'data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv' before = table.read_bytes() record = build(implementation, crate(tmp_path, 'doi:' + BARE), table) - assert record['doi'] == BARE and 'id' not in record - with pytest.raises(publication.PublicationError, match='id'): - publication.prepare_dataset(record) + assert record['doi'] == BARE and record['id'] == 'doi:' + BARE + assert yaml.safe_load(publication.prepare_dataset(record)) == record assert table.read_bytes() == before diff --git a/tests/test_rocrate/test_legacy_root_gates.py b/tests/test_rocrate/test_legacy_root_gates.py index 92274fdad..28f7aa085 100644 --- a/tests/test_rocrate/test_legacy_root_gates.py +++ b/tests/test_rocrate/test_legacy_root_gates.py @@ -225,6 +225,10 @@ def test_d4d_default_mapping_refuses_missing_required_id( first = crate(tmp_path / "a.json", title="First") primary = crate(tmp_path / "b.json", title="Chosen primary") + first.write_text(first.read_text().replace( + 'https://example.org/dataset/root', 'doi:10.1234/Secondary')) + primary.write_text(json.dumps({'@graph': [ + {'@type': 'Dataset', 'name': 'Chosen anonymous primary'}]})) output = tmp_path / "published" / "record.yaml" seed_artifacts(output, True) monkeypatch.chdir(tmp_path) @@ -232,8 +236,8 @@ def test_d4d_default_mapping_refuses_missing_required_id( "rocrate", "transform", "--merge", "--inputs", str(first), "--inputs", str(primary), "--primary", str(primary), "-o", str(output), ]) - # Current default TSV does not map required Dataset.id. This is a real - # publication refusal, not permission to invent an ID or repair the map. + # An anonymous selected primary still lacks required Dataset.id. The new + # explicit route must not borrow the secondary's identity or invent one. assert result.exit_code == 1, result.output assert "id" in result.output and "Dataset publication refused" in result.output assert_artifacts_preserved(output, True) diff --git a/tests/test_rocrate/test_legacy_root_identity.py b/tests/test_rocrate/test_legacy_root_identity.py new file mode 100644 index 000000000..3bf0ea95d --- /dev/null +++ b/tests/test_rocrate/test_legacy_root_identity.py @@ -0,0 +1,306 @@ +"""Explicit root identity works through both real legacy construction paths.""" +from copy import deepcopy +import importlib +import importlib.util +import json +from pathlib import Path +import sys + +from click.testing import CliRunner +import pytest +import yaml + +from data_sheets_schema import legacy_publication as publication +from data_sheets_schema.legacy_root_identity import MARKER, resolve_root_identity +from .test_legacy_root_gates import legacy_imports + + +REPO = Path(__file__).resolve().parents[2] +DEFAULT = REPO / 'data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv' +SENTINEL = b'previous reviewed output\n' + + +@pytest.fixture(params=['packaged', 'hidden']) +def implementation(request): + names = dict(builder='d4d_builder', loader='mapping_loader', parser='rocrate_parser', + merger='rocrate_merger', scorer='informativeness_scorer') + if request.param == 'hidden': + with legacy_imports(): + yield {key: importlib.import_module(name) for key, name in names.items()} + else: + yield {key: importlib.import_module('fairscape_integration.utils.' + name) + for key, name in names.items()} + + +def mapping(tmp_path, *, marked=True, include_id=True, fields=()): + path = tmp_path / 'selected.tsv' + path.write_text( + 'D4D Property\tType\tFAIRSCAPE RO-Crate Property\tFunc\t' + 'Covered by FAIRSCAPE? Yes =1; No = 0\tDirect mapping? Yes =1; No = 0\n' + + (f'id\tURI\tidentifier,@id\t{MARKER if marked else ""}\t1\t0\n' + if include_id else '') + + 'title\tstr\tname\t\t1\t1\n' + + ''.join(f'{field}\tstr\t{prop}\t\t1\t1\n' for field, prop in fields), + encoding='utf-8') + return path + + +def crate(path, root, *, reverse=False): + selected = {'@type': 'Dataset', **deepcopy(root)} + graph = [selected] + if selected.get('@id'): + graph = [ + {'@id': 'ro-crate-metadata.json', '@type': 'CreativeWork', + 'about': {'@id': selected['@id']}}, selected, + {'@id': 'member', '@type': 'Dataset', 'identifier': 'doi:10.9999/Member', + 'name': 'Unselected member'}, + ] + path.write_text(json.dumps({'@graph': graph[::-1] if reverse else graph}), encoding='utf-8') + return path + + +def build(implementation, source, table): + loader = implementation['loader'].MappingLoader(str(table)) + parser = implementation['parser'].ROCrateParser(str(source)) + return implementation['builder'].D4DBuilder(loader).build_dataset(parser) + + +@pytest.mark.parametrize('prefix', ['', 'doi:', 'DOI:', 'http://doi.org/', + 'https://doi.org/', 'http://dx.doi.org/', 'HTTPS://DX.DOI.ORG/']) +@pytest.mark.parametrize('reverse', [False, True]) +def test_root_scalar_doi_complete_suffix_and_publication(implementation, tmp_path, prefix, reverse): + value = prefix + '10.1234/MiXeD/Part///' + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': value, + 'name': 'Selected root'}, reverse=reverse) + table = mapping(tmp_path) + before = {path: path.read_bytes() for path in (source, table)} + record = build(implementation, source, table) + assert record == {'id': 'doi:10.1234/MiXeD/Part///', 'title': 'Selected root'} + raw = publication.prepare_dataset(record) + output = tmp_path / 'record.yaml' + publication.publish([(output, raw)], protected=[source, table]) + assert yaml.safe_load(output.read_bytes()) == record + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize('identifier', [None, '', ' \t\n']) +def test_fallback_preserves_selected_root_ark(implementation, tmp_path, identifier): + value = 'ark:59853/Case/Suffix/' + source = crate(tmp_path / 'input.json', {'@id': value, 'identifier': identifier}) + assert build(implementation, source, mapping(tmp_path))['id'] == value + + +def test_absent_identifier_falls_back_without_member_selection(implementation, tmp_path): + source = crate(tmp_path / 'input.json', {'@id': './', 'name': 'Root'}) + assert build(implementation, source, mapping(tmp_path))['id'] == './' + + +@pytest.mark.parametrize('value', ['https://example.org/Id/', 'ark:12345/Raw/', + ' doi:10.1234/Untouched/ ', '10.1234/Not A DOI', 'plain asserted identity']) +def test_nonrecognized_scalar_is_not_rewritten(implementation, tmp_path, value): + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': value}) + assert build(implementation, source, mapping(tmp_path))['id'] == value + + +@pytest.mark.parametrize('value', [False, 0, 7, [], ['doi:10.1234/One'], + ['doi:10.1234/One', 'doi:10.1234/One'], {'@id': 'doi:10.1234/One'}]) +def test_present_nonscalar_identifier_refuses_before_state_change(implementation, tmp_path, value): + table = mapping(tmp_path) + loader = implementation['loader'].MappingLoader(str(table)) + builder = implementation['builder'].D4DBuilder(loader) + source = crate(tmp_path / 'good.json', {'@id': './', 'identifier': 'doi:10.1234/Good'}) + previous = deepcopy(builder.build_dataset(implementation['parser'].ROCrateParser(str(source)))) + invalid = crate(tmp_path / 'bad.json', {'@id': './', 'identifier': value}) + before = invalid.read_bytes() + with pytest.raises(ValueError, match='root identifier must be scalar text'): + builder.build_dataset(implementation['parser'].ROCrateParser(str(invalid))) + assert builder.d4d_data == previous + assert invalid.read_bytes() == before + + +@pytest.mark.parametrize('value', [False, 0, [], ['ark:123/One'], {'@id': 'ark:123/One'}]) +def test_fallback_nonscalar_id_is_not_stringified(value): + with pytest.raises(ValueError, match='root @id must be scalar text'): + resolve_root_identity({'@id': value}) + + +def test_missing_identity_remains_missing_and_publication_refuses(implementation, tmp_path): + source = crate(tmp_path / 'input.json', {'name': 'Anonymous selected Dataset'}) + record = build(implementation, source, mapping(tmp_path)) + assert record == {'title': 'Anonymous selected Dataset'} + with pytest.raises(publication.PublicationError, match='id'): + publication.prepare_dataset(record) + + +@pytest.mark.parametrize('id_first', [False, True]) +def test_reverse_mapping_keeps_ordinary_doi_target(implementation, tmp_path, id_first): + table = mapping(tmp_path, fields=[('doi', 'identifier')]) + lines = table.read_text().splitlines() + if not id_first: + lines[1], lines[-1] = lines[-1], lines[1] + table.write_text('\n'.join(lines) + '\n') + loader = implementation['loader'].MappingLoader(str(table)) + assert loader.get_d4d_field('identifier') == 'doi' + assert loader.get_d4d_field('@id') == 'id' + assert 'id' in loader.get_covered_fields() + + +def test_custom_no_id_and_unmarked_id_retain_existing_routing(implementation, tmp_path): + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': 'doi:10.1234/Custom'}) + table = mapping(tmp_path, include_id=False) + assert 'id' not in build(implementation, source, table) + table = mapping(tmp_path, marked=False) + assert build(implementation, source, table)['id'] == 'doi:10.1234/Custom' + table.write_text(table.read_text().replace('identifier,@id', 'customId')) + source = crate(tmp_path / 'custom.json', {'@id': './', 'customId': 'https://example.org/Explicit'}) + assert build(implementation, source, table)['id'] == 'https://example.org/Explicit' + + +def test_scoring_and_tie_order_exactly_match_table_without_construction_id(implementation, tmp_path): + paths = [crate(tmp_path / f'{index}.json', root) for index, root in enumerate([ + {'@id': './', 'name': 'A', 'identifier': 'doi:10.1234/A'}, + {'@id': 'ark:123/B', 'name': 'B'}, {'name': 'Anonymous'}])] + parsers = [implementation['parser'].ROCrateParser(str(path)) for path in paths] + scorer = implementation['scorer'].InformativenessScorer() + old = implementation['loader'].MappingLoader(str(mapping(tmp_path, include_id=False))) + new = implementation['loader'].MappingLoader(str(mapping(tmp_path))) + assert scorer.rank_rocrates(parsers, new) == scorer.rank_rocrates(parsers, old) + + +@pytest.mark.parametrize('primary_index', [0, 1]) +def test_merger_keeps_only_chosen_primary_identity_and_reports_conflict( + implementation, tmp_path, primary_index): + paths = [crate(tmp_path / f'{index}.json', {'@id': './', 'name': f'Root {index}', + 'identifier': f'doi:10.1234/Identity{index}/'}) for index in range(2)] + before = {path: path.read_bytes() for path in paths} + parsers = [implementation['parser'].ROCrateParser(str(path)) for path in paths] + merger = implementation['merger'].ROCrateMerger( + implementation['loader'].MappingLoader(str(mapping(tmp_path)))) + result = merger.merge_rocrates(parsers, primary_index=primary_index, source_names=['A', 'B']) + assert result['id'] == f'doi:10.1234/Identity{primary_index}/' + assert merger.provenance['id'] == [['A', 'B'][primary_index]] + evidence = merger.root_identity_sources + assert [row['selected_primary'] for row in evidence] == [primary_index == 0, primary_index == 1] + assert [row['different_written_id'] for row in evidence] == [primary_index != 0, primary_index != 1] + report = merger.generate_merge_report(parsers, ['A', 'B']) + assert 'ROOT IDENTITY SELECTION' in report and 'not a source-coverage gain' in report + assert all(f'doi:10.1234/Identity{index}/' in report for index in range(2)) + assert {path: path.read_bytes() for path in before} == before + + +def test_secondary_cannot_fill_missing_primary_identity(implementation, tmp_path): + paths = [crate(tmp_path / 'primary.json', {'name': 'Anonymous primary'}), + crate(tmp_path / 'secondary.json', {'@id': 'ark:123/Secondary'})] + parsers = [implementation['parser'].ROCrateParser(str(path)) for path in paths] + merger = implementation['merger'].ROCrateMerger( + implementation['loader'].MappingLoader(str(mapping(tmp_path)))) + result = merger.merge_rocrates(parsers) + assert 'id' not in result and 'id' not in merger.provenance + with pytest.raises(publication.PublicationError, match='id'): + publication.prepare_dataset(result) + + +def test_later_ambiguous_identity_preserves_entire_previous_merge(implementation, tmp_path): + merger = implementation['merger'].ROCrateMerger( + implementation['loader'].MappingLoader(str(mapping(tmp_path)))) + good = implementation['parser'].ROCrateParser(str(crate( + tmp_path / 'good.json', {'@id': './', 'identifier': 'doi:10.1234/Good'}))) + merger.merge_rocrates([good]) + before = deepcopy((merger.merged_data, merger.provenance, merger.merge_stats, + merger.root_identity_sources, merger.primary_name)) + bad = implementation['parser'].ROCrateParser(str(crate( + tmp_path / 'bad.json', {'@id': './', 'identifier': ['doi:10.1234/Bad']}))) + with pytest.raises(ValueError, match='bad.json.*scalar text'): + merger.merge_rocrates([good, bad], source_names=['new good', 'bad']) + assert (merger.merged_data, merger.provenance, merger.merge_stats, + merger.root_identity_sources, merger.primary_name) == before + + +def test_default_mapping_real_cli_publishes_explicit_identity(tmp_path, monkeypatch): + from data_sheets_schema.cli import cli + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': 'https://doi.org/10.1234/Full/', + 'name': 'Selected root'}) + output = tmp_path / 'out.yaml' + before = source.read_bytes() + monkeypatch.chdir(REPO) + with legacy_imports(): + result = CliRunner().invoke(cli, ['rocrate', 'transform', str(source), '-o', str(output)]) + assert result.exit_code == 0, result.output + record = yaml.safe_load(output.read_bytes()) + assert record['id'] == 'doi:10.1234/Full/' and record['doi'] == '10.1234/Full/' + assert source.read_bytes() == before + + +def test_default_mapping_other_type_errors_still_refuse(implementation, tmp_path): + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': 'doi:10.1234/Valid', + 'name': 'Selected root', 'author': 'A source author'}) + record = build(implementation, source, DEFAULT) + assert record['id'] == 'doi:10.1234/Valid' + with pytest.raises(publication.PublicationError): + publication.prepare_dataset(record) + + +@pytest.mark.parametrize('entrypoint', ['packaged', 'd4d']) +@pytest.mark.parametrize('marked', [False, True]) +@pytest.mark.parametrize('only_id', [False, True]) +def test_actual_cli_reports_exclude_only_marked_construction_identity( + tmp_path, monkeypatch, entrypoint, marked, only_id): + table = mapping(tmp_path, marked=marked, + fields=[('description', 'description'), ('language', 'language')]) + if only_id: + table.write_text('\n'.join(table.read_text().splitlines()[:2]) + '\n') + source = crate(tmp_path / 'input.json', {'@id': './', 'identifier': 'doi:10.1234/Id', + 'name': 'Actual source title'}) + before = {path: path.read_bytes() for path in (table, source)} + output = tmp_path / 'record.yaml' + monkeypatch.chdir(REPO) + with legacy_imports(): + if entrypoint == 'packaged': + from fairscape_integration.cli import cli + result = CliRunner().invoke(cli, ['transform', str(source), '-m', str(table), + '-o', str(output), '--report']) + report_path = tmp_path / 'record_report.txt' + else: + from data_sheets_schema.cli import cli + result = CliRunner().invoke(cli, ['rocrate', 'transform', str(source), + '--mapping', str(table), '-o', str(output)]) + report_path = tmp_path / 'transformation_report.txt' + assert result.exit_code == 0, result.output + assert yaml.safe_load(output.read_bytes())['id'] == 'doi:10.1234/Id' + count = (0 if only_id else 1) + (0 if marked else 1) + denominator = (0 if only_id else 3) + (0 if marked else 1) + percentage = count / denominator * 100 if denominator else 0 + report = report_path.read_text() + if entrypoint == 'packaged': + assert f'Fields populated: {count}/{denominator}' in report + assert f'Coverage: {percentage:.1f}%' in report + assert f'Coverage: {percentage:.1f}%' in result.output + else: + assert f'Coverage: {count}/{denominator} ({percentage:.1f}%)' in report + assert f'Coverage: {count}/{denominator} mapped fields' in result.output + assert f'Percentage: {percentage:.1f}%' in result.output + assert ('Required Dataset.id construction is excluded' in report) is marked + assert {path: path.read_bytes() for path in before} == before + + +def test_dataset_api_batch_ambiguous_later_id_preserves_all_outputs(tmp_path, monkeypatch): + inputs, outputs = tmp_path / 'inputs', tmp_path / 'outputs' + inputs.mkdir() + outputs.mkdir() + paths = [crate(inputs / 'a.json', {'@id': './', 'identifier': 'doi:10.1234/A'}), + crate(inputs / 'z.json', {'@id': './', 'identifier': ['doi:10.1234/Z']})] + table = mapping(tmp_path) + for name in ('a_d4d.yaml', 'z_d4d.yaml'): + (outputs / name).write_bytes(SENTINEL) + before = {path: path.read_bytes() for path in [*paths, table, *outputs.iterdir()]} + monkeypatch.chdir(REPO) + with legacy_imports(): + spec = importlib.util.spec_from_file_location( + '_root_gates_transform_api', REPO / 'src/transformation/transform_api.py') + api = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = api + spec.loader.exec_module(api) + with pytest.raises(ValueError, match='scalar text'): + api.batch_transform_rocrates(inputs, outputs, mapping_file=table, + result_contract='dataset_v1', validate=False) + assert {path: path.read_bytes() for path in before} == before diff --git a/tests/test_rocrate/test_legacy_root_identity_adversarial.py b/tests/test_rocrate/test_legacy_root_identity_adversarial.py new file mode 100644 index 000000000..b5fcb8270 --- /dev/null +++ b/tests/test_rocrate/test_legacy_root_identity_adversarial.py @@ -0,0 +1,244 @@ +"""Independent identity-route controls: real rank reversal and coverage joins. + +These use actual legacy loaders, parsers, builders, scorers, mergers and API. +The unmarked-ID arm is an observable counterexample to counting the new row; +it must change rank while the construction-only marked arm must not. +""" +from copy import deepcopy +import csv +import importlib +import importlib.util +import json +from pathlib import Path +import sys + +import pytest + +from data_sheets_schema import legacy_root_identity as identity +from .test_legacy_root_gates import legacy_imports + + +ROOT = Path(__file__).resolve().parents[2] +COLUMNS = ['D4D Property', 'Type', 'FAIRSCAPE RO-Crate Property', 'Func', + identity.COVERED, 'Direct mapping? Yes =1; No = 0'] +FIELDS = [('title', 'name'), ('version', 'sourceOnly'), ('license', 'license'), + ('citation', 'citation'), ('language', 'language'), ('format', 'format'), + ('publisher', 'publisher'), ('purpose', 'purpose')] + + +@pytest.fixture(params=['packaged', 'hidden']) +def modules(request): + names = ('d4d_builder', 'mapping_loader', 'rocrate_parser', + 'rocrate_merger', 'informativeness_scorer') + if request.param == 'hidden': + with legacy_imports(): + yield {name: importlib.import_module(name) for name in names} + else: + yield {name: importlib.import_module('fairscape_integration.utils.' + name) + for name in names} + + +def table(path, route='absent', *, extra=()): + rows = [[field, 'str', prop, '', '1', '1'] for field, prop in FIELDS] + if route != 'absent': + rows.append(['id', 'str', 'identifier,@id', + identity.MARKER if route == 'marked' else route, '1', '0']) + rows.extend(extra) + with path.open('w', encoding='utf-8', newline='') as handle: + writer = csv.writer(handle, delimiter='\t') + writer.writerow(COLUMNS) + writer.writerows(rows) + return path + + +def crate(path, root): + value = {'@id': './', '@type': 'Dataset', **deepcopy(root)} + path.write_text(json.dumps({'@graph': [ + {'@id': 'ro-crate-metadata.json', '@type': 'CreativeWork', + 'about': {'@id': value['@id']}}, value, + ]}), encoding='utf-8') + return path + + +def ranked_inputs(tmp_path): + # A: 2/8 covered + 1/2 unique = .25. B: 1/8 covered + .75 + # richness and .25 technical = .225. A common unmarked ID changes the + # denominators: A=.23333, B=.26389, so B becomes the primary. + first = crate(tmp_path / 'ordinary-primary.json', { + 'identifier': 'ark:12345/ordinary', 'name': 'Ordinary primary', + 'sourceOnly': '1'}) + second = crate(tmp_path / 'richer-secondary.json', { + 'identifier': 'ark:12345/richer', 'name': 'Richer secondary', + 'description': 'Retained source description. ' * 12, + 'additionalProperty': [{'name': 'retained', 'value': 'text'}], + 'generatedBy': 'retained workflow', 'contentUrl': 'https://example.org/data'}) + return [first, second] + + +def ranking(modules, sources, mapping): + parsers = [modules['rocrate_parser'].ROCrateParser(str(path)) for path in sources] + scorer = modules['informativeness_scorer'].InformativenessScorer() + loader = modules['mapping_loader'].MappingLoader(str(mapping)) + ranked = scorer.rank_rocrates(parsers, loader) + return [(Path(parser.rocrate_path), score, rank) for parser, score, rank in ranked] + + +def test_marked_identity_preserves_real_ranking_where_naive_id_reverses_it(modules, tmp_path): + sources = ranked_inputs(tmp_path) + before = {path: path.read_bytes() for path in sources} + original = table(tmp_path / 'original.tsv') + marked = table(tmp_path / 'explicit-copied-table.tsv', 'marked') + custom = table(tmp_path / 'custom.tsv', '') + old = ranking(modules, sources, original) + new = ranking(modules, sources, marked) + naive = ranking(modules, sources, custom) + assert old == new # Complete scores, paths and ranks, not just order. + assert [row[0] for row in old] == sources + assert [row[0] for row in naive] == sources[::-1] + assert old[0][1]['total_score'] == pytest.approx(.25) + assert old[1][1]['total_score'] == pytest.approx(.225) + assert old[0][1]['d4d_coverage'] == 2 + assert naive[0][1]['d4d_coverage'] == 2 + assert naive[1][1]['d4d_coverage'] == 3 + assert {path: path.read_bytes() for path in before} == before + + +@pytest.fixture +def api(): + with legacy_imports(): + name = '_root_gates_transform_api' + spec = importlib.util.spec_from_file_location( + name, ROOT / 'src/transformation/transform_api.py') + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + yield module + + +def transformer(api, mapping, *, contract='dataset_v1', provenance=True): + # These controls inspect the actual no-output draft result. Publication + # validation is not replaced, stubbed or invoked by this test helper. + return api.SemanticTransformer(api.TransformationConfig( + mapping_file=mapping, validate_input=False, validate_output=False, + result_contract=contract, preserve_provenance=provenance)) + + +@pytest.mark.parametrize('contract', ['legacy', 'dataset_v1']) +def test_api_source_coverage_excludes_only_explicit_construction_id(api, tmp_path, contract): + source = ranked_inputs(tmp_path)[0] + mappings = {kind: table(tmp_path / (kind + '.tsv'), route) + for kind, route in [('old', 'absent'), ('marked', 'marked'), ('custom', '')]} + before = {path: path.read_bytes() for path in [source, *mappings.values()]} + results = {kind: transformer(api, mapping, contract=contract).rocrate_to_d4d(source) + for kind, mapping in mappings.items()} + old, marked, custom = (results[key] for key in ('old', 'marked', 'custom')) + assert old.coverage_percentage == marked.coverage_percentage == 25.0 + assert old.unmapped_fields == marked.unmapped_fields + assert 'id' not in marked.unmapped_fields + assert custom.coverage_percentage == pytest.approx(100 / 3) + assert 'id' not in old.data + assert marked.data['id'] == custom.data['id'] == 'ark:12345/ordinary' + for result in results.values(): + assert result.transformation_metadata['coverage_percentage'] == result.coverage_percentage + assert result.transformation_metadata['unmapped_fields'] == result.unmapped_fields + assert {path: path.read_bytes() for path in before} == before + + +def test_actual_api_auto_merge_keeps_primary_and_non_identity_output(api, tmp_path): + sources = ranked_inputs(tmp_path) + original = table(tmp_path / 'old.tsv') + marked = table(tmp_path / 'new.tsv', 'marked') + custom = table(tmp_path / 'custom.tsv', '') + results = [transformer(api, mapping).merge_rocrates(sources) + for mapping in (original, marked, custom)] + old, new, naive = results + assert old['transformation_metadata']['source_order'] == [str(path) for path in sources] + assert new['transformation_metadata']['source_order'] == old['transformation_metadata']['source_order'] + assert naive['transformation_metadata']['source_order'] == [str(path) for path in sources[::-1]] + assert new['data']['id'] == 'ark:12345/ordinary' + assert naive['data']['id'] == 'ark:12345/richer' + assert {key: value for key, value in new['data'].items() if key != 'id'} == old['data'] + assert 'ROOT IDENTITY SELECTION' in new['merge_report'] + assert 'ROOT IDENTITY SELECTION' not in old['merge_report'] + + +@pytest.mark.parametrize('field', ['', 'D4D: structural heading', 'title']) +@pytest.mark.parametrize('covered', ['0', '1']) +def test_ignored_or_wrong_field_marker_is_rejected_before_loader_skips(modules, tmp_path, field, covered): + path = table(tmp_path / 'wrong-row.tsv', extra=[ + [field, 'str', 'identifier,@id', identity.MARKER, covered, '0']]) + before = path.read_bytes() + with pytest.raises(ValueError, match='root_identifier_v1'): + modules['mapping_loader'].MappingLoader(str(path)) + assert path.read_bytes() == before + + +@pytest.mark.parametrize('rows', [ + [['id', 'str', '@id,identifier', identity.MARKER, '1', '0']], + [['id', 'str', 'identifier,@id,memberId', identity.MARKER, '1', '0']], + [['id', 'str', 'identifier,@id', identity.MARKER, '0', '0']], + [['id', 'str', 'identifier,@id', identity.MARKER, '1', '0'], + ['id', 'str', 'customId', '', '1', '1']], + [['id', 'str', 'identifier,@id', identity.MARKER, '1', '0'], + ['id', 'str', 'identifier,@id', identity.MARKER, '1', '0']], +]) +def test_incompatible_and_ambiguous_route_tables_refuse(modules, tmp_path, rows): + mapping = table(tmp_path / 'conflicting.tsv', extra=rows) + with pytest.raises(ValueError, match='root_identifier_v1'): + modules['mapping_loader'].MappingLoader(str(mapping)) + + +def test_unmarked_arbitrary_function_remains_inert_and_custom_id_is_not_normalized(modules, tmp_path): + effect = tmp_path / 'not-created' + expression = f"__import__('pathlib').Path({str(effect)!r}).touch()" + mapping = table(tmp_path / 'custom-expression.tsv', expression) + source = crate(tmp_path / 'input.json', { + 'identifier': 'https://doi.org/10.1234/MiXeD///', 'name': 'Source'}) + loader = modules['mapping_loader'].MappingLoader(str(mapping)) + parser = modules['rocrate_parser'].ROCrateParser(str(source)) + result = modules['d4d_builder'].D4DBuilder(loader).build_dataset(parser) + assert result['id'] == 'https://doi.org/10.1234/MiXeD///' + assert 'id' in identity.scoring_fields(loader) + assert not effect.exists() + + +@pytest.mark.parametrize('source', [ + ' https://doi.org/10.1234/MiXeD///', 'doi:10.1234/MiXeD///\t', + 'https://example.org/?value=doi:10.1234/MiXeD///', + 'ark:/12345/Record%2fMiXeD///', 'https://doi.org/10.1234/Has space/', +]) +def test_whole_value_recognition_preserves_outside_contract_text(modules, tmp_path, source): + path = crate(tmp_path / 'input.json', {'identifier': source, 'name': 'Source'}) + before = path.read_bytes() + loader = modules['mapping_loader'].MappingLoader(str(table(tmp_path / 'selected.tsv', 'marked'))) + parser = modules['rocrate_parser'].ROCrateParser(str(path)) + result = modules['d4d_builder'].D4DBuilder(loader).build_dataset(parser) + assert result['id'] == source + assert path.read_bytes() == before + + +def test_identity_evidence_detaches_even_unused_raw_input(): + root = {'identifier': 'https://doi.org/10.1234/MiXeD%2fPart///', + '@id': {'source': ['retained unused assertion']}} + before = deepcopy(root) + evidence = identity.resolve_root_identity(root) + assert evidence['id'] == 'doi:10.1234/MiXeD%2fPart///' + assert evidence['source_property'] == 'identifier' + assert evidence['source_value'] == root['identifier'] + assert evidence['inputs']['@id']['value'] == root['@id'] + evidence['inputs']['@id']['value']['source'].append('caller mutation') + assert root == before + + +def test_reused_marked_merger_does_not_leak_identity_into_unmarked_no_id_table(modules, tmp_path): + source = crate(tmp_path / 'input.json', {'identifier': 'ark:12345/retained', 'name': 'Source'}) + parser = modules['rocrate_parser'].ROCrateParser(str(source)) + marked = modules['mapping_loader'].MappingLoader(str(table(tmp_path / 'marked.tsv', 'marked'))) + old = modules['mapping_loader'].MappingLoader(str(table(tmp_path / 'old.tsv'))) + merger = modules['rocrate_merger'].ROCrateMerger(marked) + assert merger.merge_rocrates([parser])['id'] == 'ark:12345/retained' + assert merger.root_identity_sources is not None + merger.mapping = old + assert merger.merge_rocrates([parser]) == {'title': 'Source'} + assert merger.root_identity_sources is None + assert 'id' not in merger.provenance From f6e13a1e7624d7cdc7e0cf926f62a972c02b2763 Mon Sep 17 00:00:00 2001 From: "marcin p. joachimiak" <4625870+realmarcin@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:38:16 -0700 Subject: [PATCH 5/5] Record reviewed five-input root identity comparison --- .../legacy_root_identity_2026-10-08/README.md | 162 ++- .../compare_saved.py | 267 ++++ .../replay_supplement.py | 262 ++++ .../validation.json | 1246 +++++++++++++++++ 4 files changed, 1917 insertions(+), 20 deletions(-) create mode 100644 notes/legacy_root_identity_2026-10-08/compare_saved.py create mode 100644 notes/legacy_root_identity_2026-10-08/replay_supplement.py create mode 100644 notes/legacy_root_identity_2026-10-08/validation.json diff --git a/notes/legacy_root_identity_2026-10-08/README.md b/notes/legacy_root_identity_2026-10-08/README.md index 1b85437a5..fb22a39f6 100644 --- a/notes/legacy_root_identity_2026-10-08/README.md +++ b/notes/legacy_root_identity_2026-10-08/README.md @@ -41,23 +41,145 @@ legacy API metadata still requires the explicit `dataset_v1` result contract for Dataset-only publication. Neither another mapper nor a generated identifier is substituted on failure. -## Validation status - -Implementation and focused tests are prepared for independent static review. -No tests, five-input replay, provider calls or real-record publication have been -run for this change yet. Tests exercise both real loaders, constructors, scorers -and mergers; complete DOI suffixes, graph reordering, fallback and refusal; -custom-table compatibility; merge preflight and primary-only identity; mandatory -publication and existing output preservation. Independent controls exercise a -real ranking reversal when an unmarked construction row is incorrectly counted, -and source-coverage reporting across result contracts. - -The separately scheduled five-input comparison must retain the CHORUS, VOICE, -CM4AI reduced, CM4AI original archive member, and VOICE provenance evidence. It -must compare against the DOI-fixed predecessor and record all remaining -validation errors, with source preservation and unchanged ranking. Existing -historical records and the 43-row accepted-policy snapshot remain unchanged. - -Parent issues #4594 and #2915 remain open for the broader construction, -validation, comparison and publication obligations. This repair does not resolve -human scientific review, applicability, routing or paid-execution holds. +## Completed validation + +The reviewed implementation at `180d1028bb25332ff296eafcbfb4533d3f6ee85d` +(tree `a6d85ba51d120b1a41a74e5da4249e17a36dbf7f`) passed **570 tests** +in 14 modules: zero failures, errors or skips, 45.483 seconds in JUnit. The +coordinator recorded 45.52 seconds and 14 dependency deprecation warnings in the +console. Tests include 95 new identity cases and 42 independent adversarial +cases, both real producer paths, custom-table compatibility, malformed identity +refusals, primary-only merging, output preservation, scorer/rank behavior and +API/CLI coverage accounting. JUnit does not itself attest the Git commit or +console warning count; those bindings are coordinator run provenance. + +The fresh replay baseline is the DOI-fixed +`f2f1b3f465fa1a06adf150285d2ec1e9083a7839` (production predecessor +`1a98255bb28451500d4864876a16d9c6c2f6b435`). The unchanged historical +[parser replay](../parser_root_policy_2026-10-07/replay.py) produced twenty +records per arm: five retained inputs × two implementations × original/reversed +graph order. The VOICE provenance graph remains a node object. The exact +[replay supplement](replay_supplement.py) then called the real mandatory +`prepare_dataset` gate on those saved records, ranked the five sources and ran +fresh real mergers. All collections completed with their full denominators and +no infrastructure errors or missing rows. + +The stdlib-only [saved-evidence comparator](compare_saved.py) checked the complete +canonical records, roots, score dictionaries, rank order, non-ID merge +provenance, identity disclosures, validation messages and saved report text. +It imports no producer or validator code. All twenty candidate records equal +their baseline after removing only the new top-level `id`; every original root +and every remaining validation error is preserved. Both implementations and +graph orders agree. + +| Retained input | Constructed ID | Baseline errors | Candidate errors | +|---|---|---:|---:| +| CHORUS | `doi:10.18130/V3/XNBOPG` | 32 | 31 | +| VOICE | `doi:10.13026/k81f-qr68` | 152 | 151 | +| CM4AI reduced | `doi:10.18130/V3/HIGT4C` | 77 | 76 | +| VOICE provenance | `ark:59853/b2ai-voice-dataset-feature-ppgs` | 1 | 0 | +| CM4AI original | `doi:10.18130/V3/HIGT4C` | 77 | 76 | + +These counts apply to each implementation/order and are the full retained +validator errors, not a quality score. The mandatory Dataset gate refused all +20 baseline records. It accepted the four candidate versions of **one** VOICE +provenance input and refused the other 16. Accepted bytes were prepared and +hashed in memory; they were not published. Their full record equals the saved +YAML draft, whose serialization has a different byte hash. Prepared byte +contents were not separately retained. + +All four complete score/rank groups are unchanged. The ranking remains VOICE, +CM4AI reduced, CM4AI original, CHORUS, VOICE provenance. Eight real merge pairs +cover that ranked primary and an explicit VOICE provenance primary, across +both implementations/orders. They retain the fixed secondary source order; +this is not a full auto-reordered CLI replay. Non-ID records and provenance +match exactly. Only `fields_from_primary` and `total_unique_fields` increase by +one for the constructed ID; all other statistics match. **This is construction +accounting, not improved source coverage.** All eight mixed merges per arm +still refuse Dataset publication. The mixed roster is a software control, not a +scientific combined dataset. + +All forty candidate merge identity disclosures retain the five raw source +assertions, selected primary, property choice and differing written IDs. Merge +report text differs only by the verified disclosure section, ID contribution, +corresponding construction counts and observed timestamp. No identity +agreement or entity equivalence is inferred. + +## Evidence and preservation + +[validation.json](validation.json) records the tested tree, 43 candidate +source/test/resource pins, 25 baseline source/resource pins, driver and artifact +identities, all error/gate denominators and full compact comparison results. +The complete tested implementation path list also includes API and both CLI +changes for #4674; the comparator's `declared_source_changes` lists only its +narrower selected replay-source deltas. + +The full checkout inventories before and after all four executions are byte +identical: SHA256 +`6a4d05992df40fa97145b5b8ff68509d3eff474cff1a5a7e5793db8876673529`. +They cover 9,278 baseline and 9,281 candidate actual files, including hidden and +ignored entries except `.git`, with byte identities and modes. The independent +reviewer rechecked those original entries against the saved inventory. This +note and its evidence utilities are added afterward. Historical parser/DOI +results, original input files, the ZIP/member and historical generated outputs +remain unchanged. + +The supplement separately verified 71 saved replay files per arm and its selected +79/80 source/history files. Optional FAIRSCAPE model-unavailable warnings were +observed during replay; the actual legacy parser/builders and Dataset gate ran. +This result does not establish availability of that optional integration. +CPython 3.13.12, selected dependency versions and executable byte identity are +recorded; this is not a complete installed-environment or import-cache snapshot. +Full drafts, raw records, report paths and complete inventories remain external. + +## Reproduction using fresh external destinations + +Set `BASELINE` and `CANDIDATE` to the exact commits above, `PYTHON` to the selected +environment, and `EVIDENCE` to a fresh external directory. Protect both source +checkouts and all original artifacts before using the historical replay, which +only checks its selected checkout. Run each arm in a fresh process, sequentially, +with bytecode writes disabled. The coordinator's focused test command and module +list are retained with symbolic paths in `validation.json`. + +```sh +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$BASELINE/src:$BASELINE" \ + "$PYTHON" -B "$BASELINE/notes/parser_root_policy_2026-10-07/replay.py" \ + --repo "$BASELINE" --output "$EVIDENCE/baseline-replay" +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$CANDIDATE/src:$CANDIDATE" \ + "$PYTHON" -B "$CANDIDATE/notes/parser_root_policy_2026-10-07/replay.py" \ + --repo "$CANDIDATE" --output "$EVIDENCE/candidate-replay" + +PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B "$NOTES/replay_supplement.py" \ + --repo "$BASELINE" --expect-commit f2f1b3f465fa1a06adf150285d2ec1e9083a7839 \ + --role baseline --replay "$EVIDENCE/baseline-replay" \ + --output "$EVIDENCE/baseline-supplement" \ + --protect-repo "$BASELINE" --protect-repo "$CANDIDATE" +PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B "$NOTES/replay_supplement.py" \ + --repo "$CANDIDATE" --expect-commit 180d1028bb25332ff296eafcbfb4533d3f6ee85d \ + --role candidate --replay "$EVIDENCE/candidate-replay" \ + --output "$EVIDENCE/candidate-supplement" \ + --protect-repo "$BASELINE" --protect-repo "$CANDIDATE" + +"$PYTHON" -B "$NOTES/compare_saved.py" \ + --baseline "$EVIDENCE/baseline-supplement" \ + --candidate "$EVIDENCE/candidate-supplement" \ + --baseline-replay "$EVIDENCE/baseline-replay" \ + --candidate-replay "$EVIDENCE/candidate-replay" \ + --baseline-commit f2f1b3f465fa1a06adf150285d2ec1e9083a7839 \ + --candidate-commit 180d1028bb25332ff296eafcbfb4533d3f6ee85d \ + --output "$EVIDENCE/comparison.json" +``` + +`NOTES` names this notes directory from the evidence commit; the tested candidate +checkout itself remains at its recorded implementation commit. The comparator +writes only a new sibling file of the four evidence directories. It verifies +saved declarations and payloads without reading original paths; source/runtime +authentication remains outside its claim. Its report normalization is limited +to the explicitly enumerated differences above. + +Per-arm collection completion, cross-arm equivalence and Dataset validity are +separate results. None establishes scientific correctness or general legacy +producer validity. No rows were retired and no semantic mapping labels were +upgraded. Parent issues #4594 and #2915 remain open for broader construction, +validation, comparison and publication obligations. Pending human scientific +reviews, applicability, routing and paid-execution decisions remain pending. diff --git a/notes/legacy_root_identity_2026-10-08/compare_saved.py b/notes/legacy_root_identity_2026-10-08/compare_saved.py new file mode 100644 index 000000000..1e812b9d0 --- /dev/null +++ b/notes/legacy_root_identity_2026-10-08/compare_saved.py @@ -0,0 +1,267 @@ +"""Compare two completed root-ID replay arms using saved evidence only (stdlib).""" +import argparse +import hashlib +import itertools +import json +from pathlib import Path +import re + +NAMES = ('CHORUS', 'VOICE', 'CM4AI_reduced', 'VOICE_provenance', 'CM4AI_original') +KINDS, ORDERS = ('packaged', 'hidden'), ('original', 'reversed') +MODES = ('ranked_primary', 'voice_provenance_primary') +IDS = dict(zip(NAMES, ('doi:10.18130/V3/XNBOPG', 'doi:10.13026/k81f-qr68', + 'doi:10.18130/V3/HIGT4C', 'ark:59853/b2ai-voice-dataset-feature-ppgs', 'doi:10.18130/V3/HIGT4C'))) +ID_ERROR = "'id' is a required property in /" +LIMIT = 64 * 1024 * 1024 + + +def check(ok, why): + if not ok: + raise ValueError(why) + + +def canonical(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False).encode() + + +def equal(a, b, why): + check(canonical(a) == canonical(b), why) + + +def raw(path): + check(path.is_file() and not path.is_symlink(), 'Not a regular evidence file') + with path.open('rb') as handle: + value = handle.read(LIMIT + 1) + check(len(value) <= LIMIT, 'Evidence exceeds byte bound') + return value + + +def sha(value): + return hashlib.sha256(value).hexdigest() + + +def pin(path): + value = raw(path) + return {'bytes': len(value), 'sha256': sha(value)} + + +def pairs(items): + result = {} + for key, value in items: + check(key not in result, 'Duplicate JSON key') + result[key] = value + return result + + +def document(path): + return json.loads(raw(path), object_pairs_hook=pairs, + parse_constant=lambda value: check(False, 'Nonfinite JSON')) + + +def local(directory, name): + check(isinstance(name, str) and name and not Path(name).is_absolute(), 'Invalid captured relative name') + path = directory / name + check(directory in path.resolve().parents, 'Captured path escapes evidence directory') + return path + + +def indexed(rows, fields, required): + result = {} + for row in rows: + key = tuple(row[name] for name in fields) + check(key not in result and row['status'] == 'recorded', 'Duplicate or failed evidence row') + result[key] = row + check(set(result) == set(required), 'Missing or unexpected evidence denominator') + return result + + +def without_id(record): + return {key: value for key, value in record.items() if key != 'id'} + + +def verify_arm(directory, replay, role, commit): + d = document(directory / 'summary.json') + check(d['format'] == 'legacy_root_identity_supplement_v1' and d['role'] == role and d['commit'] == commit, 'Wrong arm identity') + equal(d['roster'], list(NAMES), 'Unexpected input roster') + equal(d['expected'], {'records': 20, 'rankings': 4, 'merges': 8}, 'Unexpected denominator') + equal(d['missing'], {'records': 0, 'rankings': 0, 'merges': 0}, 'Missing controls') + check(d['errors'] == [] and d['gate_errors'] == [], 'Recorded collection errors') + for key in ('collection_complete', 'inputs_preserved', 'replay_preserved', 'runtime_preserved', 'driver_preserved'): + check(d[key] is True, 'Collection/preservation failed') + check(d['scientific_eligibility'] is False and d['historical_publication'] is False, 'Unexpected acceptance claim') + equal(d['preservation_before'], d['preservation_after'], 'Selected files changed') + for name, identity in d['source_pins'].items(): + equal(identity, d['preservation_before'][name], 'Source preservation pin differs') + for name, identity in d['replay_pins'].items(): + equal(pin(local(replay, name)), identity, 'Saved replay pin differs') + old = document(replay / 'summary.json') + for name, identity in old['code_sha256'].items(): + check(d['source_pins'][name]['sha256'] == identity, 'Replay/source declarations differ') + for key in ('mapping', 'schema'): + check(d['source_pins'][old[key]['path']]['sha256'] == old[key]['sha256'], 'Resource declarations differ') + rows = indexed(d['rows'], ('input', 'implementation', 'order'), itertools.product(NAMES, KINDS, ORDERS)) + ranks = indexed(d['rankings'], ('implementation', 'order'), itertools.product(KINDS, ORDERS)) + merges = indexed(d['merges'], ('implementation', 'order', 'mode'), itertools.product(KINDS, ORDERS, MODES)) + for (name, kind, order), row in rows.items(): + previous = old['inputs'][name]['consumers'][kind][order] + check(previous['parse'] == previous['build'] == 'PASS', 'Replay parse/build failed') + check(sha(canonical(row['root'])) == previous['root_sha256'], 'Root binding differs') + check(sha(canonical(row['record'])) == previous['record_sha256'], 'Record binding differs') + equal(row['original_validation_errors'], previous['validation_errors'], 'Validation errors differ') + equal(row['original_validation'], previous['validation'], 'Validation outcome differs') + gate = row['gate'] + errors = previous['validation_errors'] + check(gate['status'] == ('refused' if errors else 'accepted'), 'Gate status and actual validator disagree') + if errors: + schema = old['schema']['sha256'] + expected = f'Dataset publication refused against data_sheets_schema_all.yaml (sha256 {schema}): ' + '; '.join(errors) + '.' + equal(gate['exception'], {'type': 'data_sheets_schema.legacy_publication.PublicationError', 'message': expected, 'args': repr((expected,))}, 'Exact Dataset gate refusal differs') + else: + check(type(gate['bytes']) is int and gate['bytes'] > 0 and re.fullmatch('[0-9a-f]{64}', gate['sha256']), 'Invalid accepted-byte pin') + for row in merges.values(): + equal(pin(local(directory, row['report']['path'])), {k: row['report'][k] for k in ('bytes', 'sha256')}, 'Merge report changed') + check(row['gate']['status'] == 'refused' and row['gate']['exception']['type'] == 'data_sheets_schema.legacy_publication.PublicationError', 'Mixed merge gate outcome differs') + return d, rows, ranks, merges + + +def normalize_report(text, row, baseline, candidate): + if candidate: + begin = text.index('\n\nROOT IDENTITY SELECTION\n') + end = text.index('\nMERGE STATISTICS\n', begin) + section = text[begin:end] + check('Different written IDs do not assert entity equivalence.' in section, 'Missing identity limitation') + check('it is not a source-coverage gain.' in section, 'Missing coverage limitation') + disclosed = [json.loads(line) for line in section.splitlines() if line.startswith('{')] + equal(disclosed, row['root_identity_sources'], 'Report identity disclosure differs') + text = text[:begin] + text[end:] + for label, key in (('Total unique D4D fields', 'total_unique_fields'), ('Fields from primary only', 'fields_from_primary')): + old, new = f'{label}: {row["statistics"][key]}', f'{label}: {baseline["statistics"][key]}' + check(text.count(old) == 1, 'Missing report construction statistic') + text = text.replace(old, new) + primary = row['primary_name'] + pattern = r'(' + re.escape(f'{row["primary_index"]+1}. {primary} (PRIMARY)') + r'\n - Size: [^\n]+\n - D4D fields contributed: )(\d+)' + text, count = re.subn(pattern, lambda m: m[1] + str(int(m[2]) - 1), text) + check(count == 1, 'Missing primary contribution line') + check(text.count(f' • id: {primary}\n') == 1, 'Missing identity contribution line') + text = text.replace(f' • id: {primary}\n', '') + text, count = re.subn(r'General \((\d+) fields\):', lambda m: f'General ({int(m[1])-1} fields):', text) + check(count == 1, 'Missing identity category') + text, count = re.subn(r'^Generated: [^\n]+$', 'Generated: ', text, flags=re.M) + check(count == 1, 'Missing observed report timestamp') + return text + + +def compare(args): + paths = [path.resolve() for path in (args.baseline, args.candidate, args.baseline_replay, args.candidate_replay)] + b, br, bk, bm = verify_arm(paths[0], paths[2], 'baseline', args.baseline_commit) + c, cr, ck, cm = verify_arm(paths[1], paths[3], 'candidate', args.candidate_commit) + check(args.baseline_commit != args.candidate_commit, 'Same comparison role') + equal(b['runtime'], c['runtime'], 'Runtime declarations differ') + equal(b['driver'], c['driver'], 'Supplement driver differs') + changed = {name for name, identity in b['source_pins'].items() if c['source_pins'].get(name) != identity} + allowed = {'data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv'} | { + prefix + name + '.py' + for prefix in ('src/fairscape_integration/utils/', '.claude/agents/scripts/') + for name in ('d4d_builder', 'mapping_loader', 'informativeness_scorer', 'rocrate_merger')} + check(changed == allowed, 'Unexpected source/resource declaration change') + check(set(c['source_pins']) - set(b['source_pins']) == {'src/data_sheets_schema/legacy_root_identity.py'}, 'Unexpected new source declaration') + records = [] + for key, left in br.items(): + right = cr[key] + equal(left['root'], right['root'], 'Selected root changed') + check('id' not in left['record'] and right['record']['id'] == IDS[key[0]], 'Unexpected constructed ID') + equal(left['record'], without_id(right['record']), 'Non-ID record changed') + check(left['original_validation_errors'].count(ID_ERROR) == 1, 'Missing baseline identity error') + equal([e for e in left['original_validation_errors'] if e != ID_ERROR], right['original_validation_errors'], 'Non-ID validation errors changed') + records.append({'input': key[0], 'implementation': key[1], 'order': key[2], 'id': right['record']['id'], + 'baseline_errors': len(left['original_validation_errors']), 'candidate_errors': len(right['original_validation_errors']), + 'remaining_errors_sha256': sha(canonical(right['original_validation_errors'])), + 'baseline_gate': left['gate']['status'], 'candidate_gate': right['gate']['status']}) + for key in bk: + equal(bk[key], ck[key], 'Scores, ranks or source order changed') + merge_checks = [] + for key, left in bm.items(): + right = cm[key] + equal(left['primary_index'], right['primary_index'], 'Selected primary changed') + equal(left['primary_name'], right['primary_name'], 'Selected primary name changed') + check('id' not in left['record'] and right['record']['id'] == IDS[right['primary_name']], 'Merged ID not primary-only') + equal(left['record'], without_id(right['record']), 'Non-ID merge record changed') + check('id' not in left['provenance'] and right['provenance']['id'] == [right['primary_name']], 'ID provenance not primary-only') + equal(left['provenance'], without_id(right['provenance']), 'Non-ID merge provenance changed') + expected_stats = dict(left['statistics']) + for name in ('fields_from_primary', 'total_unique_fields'): + check(type(expected_stats[name]) is int, 'Invalid construction statistic') + expected_stats[name] += 1 + equal(expected_stats, right['statistics'], 'Unexpected statistics change') + check(left['root_identity_sources'] is None, 'Unexpected baseline identity report') + check(len(right['root_identity_sources']) == len(NAMES), 'Identity disclosure denominator differs') + for i, evidence in enumerate(right['root_identity_sources']): + name = NAMES[i]; root = cr[(name, key[0], key[1])]['root'] + prop = '@id' if name == 'VOICE_provenance' else 'identifier' + expected = {'source': name, 'path': str(paths[3] / 'inputs' / (name + ('.reversed' if key[1] == 'reversed' else '') + '.json')), + 'selected_primary': i == right['primary_index'], 'id': IDS[name], 'source_property': prop, 'source_value': root[prop], + 'inputs': {k: {'present': k in root, 'value': root.get(k)} for k in ('identifier', '@id')}, + 'different_written_id': IDS[name] != right['record']['id']} + equal(evidence, expected, 'Raw identity disclosure differs') + lb = raw(local(paths[0], left['report']['path'])).decode(); rb = raw(local(paths[1], right['report']['path'])).decode() + equal(normalize_report(lb, left, left, False), normalize_report(rb, right, left, True), 'Unexplained merge report change') + le, re_ = left['gate']['exception'], right['gate']['exception'] + suffix = '; ' + ID_ERROR + '.' + check(le['message'].endswith(suffix), 'Baseline mixed merge identity error missing') + expected_message = le['message'][:-len(suffix)] + '.' + equal(re_, {'type': le['type'], 'message': expected_message, 'args': repr((expected_message,))}, 'Mixed merge gate changed beyond ID') + merge_checks.append({'implementation': key[0], 'order': key[1], 'mode': key[2], 'primary': right['primary_name'], + 'baseline_statistics': left['statistics'], 'candidate_statistics': right['statistics'], + 'baseline_report': left['report'], 'candidate_report': right['report'], + 'gate': 'refused', 'remaining_gate_sha256': sha(canonical(re_))}) + # Compare complete records/roots/errors/gates across implementations and graph order. + for arm_rows, arm_ranks, arm_merges in ((br, bk, bm), (cr, ck, cm)): + for name in NAMES: + ref = arm_rows[(name, 'packaged', 'original')] + for kind, order in itertools.product(KINDS, ORDERS): + row = arm_rows[(name, kind, order)] + for field in ('root', 'record', 'gate', 'original_validation_errors'): + equal(ref[field], row[field], 'Graph/implementation record divergence') + for kind, order in itertools.product(KINDS, ORDERS): + equal(arm_ranks[('packaged', 'original')]['rows'], arm_ranks[(kind, order)]['rows'], 'Graph/implementation ranking divergence') + for mode in MODES: + ref = arm_merges[('packaged', 'original', mode)]; row = arm_merges[(kind, order, mode)] + for field in ('record', 'provenance', 'statistics', 'primary_name', 'primary_index', 'gate'): + equal(ref[field], row[field], 'Graph/implementation merge divergence') + return {'format': 'legacy_root_identity_comparison_v1', 'verified': True, + 'artifacts': {role: {'supplement': pin(paths[i] / 'summary.json'), 'replay': pin(paths[i+2] / 'summary.json')} for i, role in enumerate(('baseline', 'candidate'))}, + 'commits': {'baseline': b['commit'], 'candidate': c['commit']}, 'denominators': {'record_pairs': 20, 'ranking_pairs': 4, 'merge_pairs': 8, 'gates_per_arm': 28}, + 'records': records, 'ranking': bk[('packaged', 'original')]['rows'], 'merges': merge_checks, + 'declared_source_changes': [name for name, identity in b['source_pins'].items() if c['source_pins'].get(name) != identity], + 'new_source_declarations': sorted(set(c['source_pins']) - set(b['source_pins'])), + 'scientific_eligibility': False, 'general_legacy_validity': False, + 'limits': ['Saved-evidence comparison; no producer, schema validator, scorer or merger executed here.', + 'Source/runtime declarations are compared, not reauthenticated from their original paths.', + 'Accepted Dataset-byte pins are saved producer observations; full records and preserved validation errors are compared directly.', + 'Report normalization permits only observed timestamp, the separately verified ID disclosure, and explicit construction-count deltas.', + 'No source-coverage, entity-equivalence, scientific-accuracy or general publication-validity conclusion.']} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('baseline', 'candidate', 'baseline-replay', 'candidate-replay', 'output'): + parser.add_argument('--' + name, required=True, type=Path) + parser.add_argument('--baseline-commit', required=True) + parser.add_argument('--candidate-commit', required=True) + args = parser.parse_args() + out = args.output.resolve() + check(all(path.resolve().parent == out.parent for path in (args.baseline, args.candidate, args.baseline_replay, args.candidate_replay)), + 'Output must be a new sibling file of the four evidence directories') + for path in (args.baseline, args.candidate, args.baseline_replay, args.candidate_replay, Path(__file__).resolve().parents[2]): + protected = path.resolve() + check(out != protected and protected not in out.parents and out not in protected.parents, 'Output overlaps protected evidence/source') + check(not out.exists() and not args.output.is_symlink(), 'Output must be new') + result = compare(args) + out.parent.mkdir(parents=True, exist_ok=True) + with out.open('xb') as handle: + handle.write(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False).encode() + b'\n') + print(json.dumps({'verified': True, **result['denominators']})) + + +if __name__ == '__main__': + main() diff --git a/notes/legacy_root_identity_2026-10-08/replay_supplement.py b/notes/legacy_root_identity_2026-10-08/replay_supplement.py new file mode 100644 index 000000000..bc51fa733 --- /dev/null +++ b/notes/legacy_root_identity_2026-10-08/replay_supplement.py @@ -0,0 +1,262 @@ +"""External #4672 supplement; run separately per reviewed checkout, never a publisher.""" +import argparse +import contextlib +import hashlib +import importlib +import importlib.metadata +import io +import json +from pathlib import Path +import subprocess +import sys +import zipfile + +sys.dont_write_bytecode = True +NAMES = ('CHORUS', 'VOICE', 'CM4AI_reduced', 'VOICE_provenance', 'CM4AI_original') +INPUTS = { + 'CHORUS': ('data/ro-crate_packages/CHORUS/raw/ro-crate-metadata.json', '6d0fc1433008a525164154c635b604c98fb620787fbdcb02b87faf9bce643774'), + 'VOICE': ('data/ro-crate_packages/VOICE/raw/ro-crate-metadata.json', 'a85057c8d60ed0e10dfac89a733a2ba399c33b7bbc5d291b89b1444dab1901d4'), + 'CM4AI_reduced': ('data/ro-crate_packages/CM4AI/processed/CM4AI_crate_metadata_reduced.json', '8f25e9478d1105a008769e25d2a2f67da8475c7218b58946f834ab448dec6610'), + 'VOICE_provenance': ('data/ro-crate_packages/VOICE/raw/ro-crate-prov-graph.json', '2cedad47ab225713aca18bd250777fb741a5d5ee639e4b0e9cc63205adb08608'), +} +ARCHIVE = 'data/ro-crate_packages/CM4AI/raw/cm4ai_release_metadata.zip' +ARCHIVE_SHA = 'a8a4fea0aa4cfb9797fe1871de064bf29563516c5de4aa834aa177fb7c6b1d4c' +MEMBER = 'cm4ai_release_metadata/ro-crate-metadata.json' +MEMBER_SHA = '836376f3a997204b14d4d8d3fba90d967b58cbe20726218c95d861b0cb11e66e' +REPLAY = 'notes/parser_root_policy_2026-10-07/replay.py' +REPLAY_SHA = '1c62c61e2cd646b23218594696ccd019989f6f8a26babc5fdbd1ccfcf351b9c2' +MAPPING = 'data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv' +SCHEMA = 'src/data_sheets_schema/schema/data_sheets_schema_all.yaml' +MODULES = ('rocrate_parser', 'mapping_loader', 'd4d_builder', 'informativeness_scorer', 'rocrate_merger', 'field_prioritizer') +HISTORY = [f'data/ro-crate_packages/{name}/processed' for name in ('CHORUS', 'VOICE', 'CM4AI')] +HISTORY += ['data/d4d_concatenated/rocrate_static_map', 'data/d4d_concatenated/rocrate_mapped', 'notes/figures/fig09_mapping_revision_2026-10-07'] +MAX_BYTES = 64 * 1024 * 1024 + + +def require(ok, message): + if not ok: + raise ValueError(message) + + +def digest(raw): + return hashlib.sha256(raw).hexdigest() + + +def encoded(value): + return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8') + + +def read(path): + require(path.is_file() and not path.is_symlink(), f'Expected regular non-symlink file: {path}') + with path.open('rb') as handle: + raw = handle.read(MAX_BYTES + 1) + require(len(raw) <= MAX_BYTES, f'File exceeds supplement bound: {path}') + return raw + + +def pin(path): + raw = read(path) + return {'bytes': len(raw), 'sha256': digest(raw)} + + +def git(repo, *args): + return subprocess.run(['git', '-C', str(repo), *args], check=True, capture_output=True).stdout + + +def write_json(path, value): + path.write_bytes(json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False).encode('utf-8') + b'\n') + + +def exception(exc): + return {'type': f'{type(exc).__module__}.{type(exc).__qualname__}', 'message': str(exc), 'args': repr(exc.args)} + + +def gate(record, publication): + try: + raw = publication.prepare_dataset(record) + import yaml + require(encoded(yaml.safe_load(raw)) == encoded(record), 'Dataset gate changed record') + return {'status': 'accepted', 'bytes': len(raw), 'sha256': digest(raw)} + except publication.PublicationError as exc: + return {'status': 'refused', 'exception': exception(exc)} + except Exception as exc: + return {'status': 'error', 'exception': exception(exc)} + + +def inventory(repo, paths): + return {name: pin(repo / name) for name in sorted(set(paths))} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo', type=Path, required=True) + parser.add_argument('--expect-commit', required=True) + parser.add_argument('--role', choices=('baseline', 'candidate'), required=True) + parser.add_argument('--replay', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--protect-repo', type=Path, action='append', required=True) + args = parser.parse_args() + repo, replay, out = (path.resolve() for path in (args.repo, args.replay, args.output)) + protected = [path.resolve() for path in args.protect_repo] + require(len(set(protected)) == 2 and repo in protected, 'Name both distinct baseline/candidate checkouts') + for source in [*protected, replay, Path(__file__).resolve(), Path(sys.executable).resolve()]: + require(out != source and source not in out.parents and out not in source.parents, + f'Output overlaps protected path: {source}') + require(not out.exists() and not args.output.is_symlink(), 'Output must be new') + require(git(repo, 'rev-parse', 'HEAD').decode().strip() == args.expect_commit, 'Unexpected checkout commit') + require(not git(repo, 'status', '--porcelain', '--untracked-files=all').strip(), 'Selected checkout must be clean') + require(digest(read(repo / REPLAY)) == REPLAY_SHA, 'Historical replay utility differs') + + summary_path = replay / 'summary.json' + previous = json.loads(read(summary_path)) + require(tuple(previous['inputs']) == NAMES, 'Replay roster/order differs') + require(previous['replay_sha256'] == REPLAY_SHA, 'Unexpected replay utility') + source_paths = set(previous['code_sha256']) | {MAPPING, SCHEMA, REPLAY, 'pyproject.toml', 'poetry.lock', + 'src/data_sheets_schema/legacy_doi.py', 'src/data_sheets_schema/legacy_publication.py', + 'src/data_sheets_schema/scope.py', 'src/fairscape_integration/constants.py', + 'notes/parser_root_policy_2026-10-07/validation.json'} + source_paths |= {prefix + name + '.py' for prefix in ('src/fairscape_integration/utils/', '.claude/agents/scripts/') for name in MODULES} + if args.role == 'candidate': + source_paths.add('src/data_sheets_schema/legacy_root_identity.py') + source_pins = inventory(repo, source_paths) + for name in source_paths: + require(read(repo / name) == git(repo, 'show', args.expect_commit + ':' + name), f'Dirty selected file: {name}') + for name, sha in previous['code_sha256'].items(): + require(source_pins[name]['sha256'] == sha, f'Replay source binding differs: {name}') + require(source_pins[MAPPING]['sha256'] == previous['mapping']['sha256'], 'Replay mapping differs') + require(source_pins[SCHEMA]['sha256'] == previous['schema']['sha256'], 'Replay schema differs') + + original = {} + for name, (relative, sha) in INPUTS.items(): + original[name] = read(repo / relative) + require(digest(original[name]) == sha, f'Original input differs: {name}') + require(digest(read(repo / ARCHIVE)) == ARCHIVE_SHA, 'CM4AI archive differs') + with zipfile.ZipFile(repo / ARCHIVE) as archive: + matches = [entry for entry in archive.infolist() if entry.filename == MEMBER] + require(len(matches) == 1 and not matches[0].is_dir() and matches[0].file_size <= MAX_BYTES, 'Invalid selected archive member') + original['CM4AI_original'] = archive.read(MEMBER) + require(digest(original['CM4AI_original']) == MEMBER_SHA, 'CM4AI member differs') + replay_paths = [summary_path] + for name in NAMES: + require(previous['inputs'][name]['input_sha256'] == digest(original[name]), f'Replay input declaration differs: {name}') + path = replay / 'inputs' / (name + '.json') + require(read(path) == original[name], f'Replay input bytes differ: {name}') + reversed_path = path.with_name(name + '.reversed.json') + document = json.loads(original[name]); reverse = dict(document) + if isinstance(document.get('@graph'), list): + reverse['@graph'] = list(reversed(document['@graph'])) + require(encoded(json.loads(read(reversed_path))) == encoded(reverse), f'Reversed graph changed: {name}') + replay_paths.extend([path, reversed_path]) + for implementation in ('packaged', 'hidden'): + for suffix in ('', '_reversed'): + stem = f'{name}_{implementation}{suffix}' + replay_paths.extend(replay / (stem + ending) for ending in ('.yaml', '.log', '.result.json')) + replay_pins = {str(path.relative_to(replay)): pin(path) for path in replay_paths} + histories = [] + for relative in HISTORY: + directory = repo / relative + require(directory.is_dir(), f'Missing historical directory: {relative}') + histories.extend(str(path.relative_to(repo)) for path in directory.rglob('*') if path.is_file()) + preservation_paths = source_paths | {p for p, _ in INPUTS.values()} | {ARCHIVE} | set(histories) + before = inventory(repo, preservation_paths) + interpreter = Path(sys.executable).resolve() + runtime = {'python': sys.version, 'executable': str(interpreter), 'identity': pin(interpreter), + 'dependencies': {name: importlib.metadata.version(name) for name in ('PyYAML', 'linkml', 'linkml-runtime')}} + + sys.path.insert(0, str(repo / 'src')) + import yaml + from data_sheets_schema import legacy_publication as publication + implementations = {} + for kind in ('packaged', 'hidden'): + if kind == 'hidden': + sys.path.insert(0, str(repo / '.claude/agents/scripts')) + modules = {name: importlib.import_module(('fairscape_integration.utils.' if kind == 'packaged' else '') + name) for name in MODULES} + for name, module in modules.items(): + prefix = 'src/fairscape_integration/utils/' if kind == 'packaged' else '.claude/agents/scripts/' + require(Path(module.__file__).resolve() == repo / (prefix + name + '.py'), 'Imported wrong producer module') + implementations[kind] = modules + require(Path(publication.__file__).resolve() == repo / 'src/data_sheets_schema/legacy_publication.py', 'Imported wrong Dataset gate') + out.mkdir(parents=True, exist_ok=False) + result = {'format': 'legacy_root_identity_supplement_v1', 'role': args.role, + 'commit': args.expect_commit, 'tree': git(repo, 'rev-parse', 'HEAD^{tree}').decode().strip(), + 'driver': pin(Path(__file__).resolve()), 'runtime': runtime, 'source_pins': source_pins, + 'replay_pins': replay_pins, 'preservation_before': before, + 'roster': list(NAMES), 'rows': [], 'rankings': [], 'merges': [], + 'expected': {'records': 20, 'rankings': 4, 'merges': 8}, + 'scientific_eligibility': False, 'historical_publication': False, + 'limitations': ['Local observed byte/commit/runtime pins, not authenticated execution or a complete environment snapshot.', + 'Preservation enumeration includes hidden/ignored files in the named historical directories only.', + 'Mixed-roster mergers are software comparisons, not scientific combined datasets.', + 'Ranked-primary merges select the actual top-ranked source while preserving the fixed source order for all other inputs; this is not an auto-reordered CLI replay.', + 'This per-arm supplement records evidence only; cross-arm comparison must compare complete records, ranks, scores and merge disclosures rather than treating collection_complete as equivalence.', + 'Dataset refusals remain evidence; no type repair, provider generation, scoring campaign or corpus publication.']} + for kind, modules in implementations.items(): + mapping = modules['mapping_loader'].MappingLoader(str(repo / MAPPING)) + for order in ('original', 'reversed'): + parsers = [] + for name in NAMES: + source = replay / 'inputs' / (name + ('.reversed' if order == 'reversed' else '') + '.json') + stem = name + '_' + kind + ('_reversed' if order == 'reversed' else '') + row = {'input': name, 'implementation': kind, 'order': order} + try: + with contextlib.redirect_stdout(io.StringIO()): + parsed = modules['rocrate_parser'].ROCrateParser(str(source)) + parsers.append(parsed) + root = parsed.require_root_dataset() + expected = previous['inputs'][name]['consumers'][kind][order] + require(expected.get('parse') == expected.get('build') == 'PASS', 'Original replay did not build this row') + record = yaml.safe_load(read(replay / (stem + '.yaml'))) + require(digest(encoded(root)) == expected['root_sha256'], 'Selected root changed') + require(digest(encoded(record)) == expected['record_sha256'], 'Replay draft record changed') + require(pin(replay / (stem + '.yaml'))['sha256'] == expected['output_sha256'], 'Replay YAML changed') + row.update(status='recorded', record=record, root=root, gate=gate(record, publication), + original_validation=expected.get('validation'), original_validation_errors=expected.get('validation_errors')) + except Exception as exc: + row.update(status='error', exception=exception(exc)) + result['rows'].append(row) + ranks = {'implementation': kind, 'order': order} + try: + require(len(parsers) == len(NAMES), 'Incomplete parser denominator') + with contextlib.redirect_stdout(io.StringIO()): + ranked = modules['informativeness_scorer'].InformativenessScorer().rank_rocrates(parsers, mapping) + ranks.update(status='recorded', rows=[{'source': NAMES[parsers.index(p)], 'index': parsers.index(p), 'scores': scores, 'rank': rank} for p, scores, rank in ranked]) + auto_index = parsers.index(ranked[0][0]) + except Exception as exc: + ranks.update(status='error', exception=exception(exc)); auto_index = None + result['rankings'].append(ranks) + for mode, index in [('ranked_primary', auto_index), ('voice_provenance_primary', NAMES.index('VOICE_provenance'))]: + merged = {'implementation': kind, 'order': order, 'mode': mode, 'primary_index': index} + log = io.StringIO() + try: + require(index is not None and len(parsers) == len(NAMES), 'Primary unavailable or incomplete parser denominator') + with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log): + merger = modules['rocrate_merger'].ROCrateMerger(mapping) + record = merger.merge_rocrates(parsers, primary_index=index, source_names=list(NAMES)) + report = merger.generate_merge_report(parsers, source_names=list(NAMES)) + report_name = f'{kind}_{order}_{mode}.txt' + (out / report_name).write_text(report, encoding='utf-8') + merged.update(status='recorded', primary_name=merger.primary_name, record=record, + provenance=merger.get_provenance(), statistics=merger.get_merge_stats(), + root_identity_sources=getattr(merger, 'root_identity_sources', None), + report={'path': report_name, **pin(out / report_name)}, gate=gate(record, publication)) + except Exception as exc: + merged.update(status='error', exception=exception(exc)) + (out / f'{kind}_{order}_{mode}.log').write_text(log.getvalue(), encoding='utf-8') + result['merges'].append(merged) + write_json(out / 'summary.json', result) + result['preservation_after'] = inventory(repo, preservation_paths) + result['inputs_preserved'] = result['preservation_after'] == before + result['replay_preserved'] = replay_pins == {str(path.relative_to(replay)): pin(path) for path in replay_paths} + result['runtime_preserved'] = pin(interpreter) == runtime['identity'] + result['driver_preserved'] = pin(Path(__file__).resolve()) == result['driver'] + result['missing'] = {key: max(0, count - len(result['rows' if key == 'records' else key])) for key, count in result['expected'].items()} + result['errors'] = [dict(section=key, index=i, exception=row['exception']) for key in ('rows', 'rankings', 'merges') for i, row in enumerate(result[key]) if row['status'] == 'error'] + result['gate_errors'] = [dict(section=key, index=i, exception=row['gate']['exception']) for key in ('rows', 'merges') for i, row in enumerate(result[key]) if row.get('gate', {}).get('status') == 'error'] + result['collection_complete'] = not any(result['missing'].values()) and not result['errors'] and not result['gate_errors'] and all(result[key] for key in ('inputs_preserved', 'replay_preserved', 'runtime_preserved', 'driver_preserved')) + write_json(out / 'summary.json', result) + print(json.dumps({'output': str(out), 'collection_complete': result['collection_complete'], 'errors': len(result['errors']), 'missing': result['missing']})) + return 0 if result['collection_complete'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/notes/legacy_root_identity_2026-10-08/validation.json b/notes/legacy_root_identity_2026-10-08/validation.json new file mode 100644 index 000000000..eb7e6dd7b --- /dev/null +++ b/notes/legacy_root_identity_2026-10-08/validation.json @@ -0,0 +1,1246 @@ +{ + "format": "legacy_root_identity_validation_v1", + "issues": { + "implementation": 4672, + "coverage_review": 4674, + "parents_remaining_open": [ + 4594, + 2915 + ] + }, + "baseline": { + "commit": "f2f1b3f465fa1a06adf150285d2ec1e9083a7839", + "tree": "94882d696404a25dc3dee83d1dacab4b957a1956", + "production_predecessor": "1a98255bb28451500d4864876a16d9c6c2f6b435", + "selected_source_pins": { + ".claude/agents/scripts/d4d_builder.py": { + "bytes": 10840, + "sha256": "d71f0fc6959a006b3d87b0458f6c12fa953e22b3dae03eb52569a2b6103ab251" + }, + ".claude/agents/scripts/field_prioritizer.py": { + "bytes": 10263, + "sha256": "031d1d0f8ac000f4c9f74eff18ea268dbfa23901fe9b52e7d5b5ddba1ed99e00" + }, + ".claude/agents/scripts/informativeness_scorer.py": { + "bytes": 12065, + "sha256": "c62469f1631b342c3ece916f87f4cd7e377e587762cde623a3848ec149594a2e" + }, + ".claude/agents/scripts/mapping_loader.py": { + "bytes": 6587, + "sha256": "0214e4f250367a93fe97cccb3ae93bf1ee8c9047f6db1fcb82ff68c582bb3e2c" + }, + ".claude/agents/scripts/rocrate_merger.py": { + "bytes": 12435, + "sha256": "f39b4aa31164dc477cb58067de002e6b19c182b61985b447eb6917da1153d68c" + }, + ".claude/agents/scripts/rocrate_parser.py": { + "bytes": 1930, + "sha256": "c50f88eecce97c2a53e81f58f6db0af94216ceb637322f6848c260dab7f80bfd" + }, + "data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv": { + "bytes": 24216, + "sha256": "28af70ed814690645ecf58b6bfd0a716d5300866c0018eaf07704387a410614e" + }, + "notes/parser_root_policy_2026-10-07/replay.py": { + "bytes": 8793, + "sha256": "1c62c61e2cd646b23218594696ccd019989f6f8a26babc5fdbd1ccfcf351b9c2" + }, + "notes/parser_root_policy_2026-10-07/validation.json": { + "bytes": 19691, + "sha256": "efa7d37ec3999b891700d85a6a5d95eda882b958f2111e6f38a6f0d42c158efc" + }, + "poetry.lock": { + "bytes": 592155, + "sha256": "923ad962d2646e1a4be044a76ff6a66abf49c3e10ea87fc85ed3cd523a907e21" + }, + "pyproject.toml": { + "bytes": 4446, + "sha256": "bac863ac39cc39d6767627e0a7d8fd085be693555e5220015afbe6aec45c0c8b" + }, + "src/data_sheets_schema/legacy_doi.py": { + "bytes": 2148, + "sha256": "6313c18f41feb74254834e886cb55ddb608e1872494a227150b7166cd780a606" + }, + "src/data_sheets_schema/legacy_publication.py": { + "bytes": 6374, + "sha256": "faa5df37620ba6f005f7e9999ddd7653054c54808efc0988ccbd28a2a44e53a9" + }, + "src/data_sheets_schema/rocrate_map.py": { + "bytes": 52876, + "sha256": "e3f3854ea2df26c5465e4ae3744f447d57efe6984262628f9e78ffe13c271592" + }, + "src/data_sheets_schema/rocrate_sources.py": { + "bytes": 11731, + "sha256": "38c85ac04a1ccdcf3382024d438f48efa577b4871bea683a562a792595907a75" + }, + "src/data_sheets_schema/schema/data_sheets_schema_all.yaml": { + "bytes": 1442513, + "sha256": "eb543e1597b29599952359818bc741b0f23eaa3a30e7aa8c63e60212c7bbb92f" + }, + "src/data_sheets_schema/scope.py": { + "bytes": 25356, + "sha256": "f586f60e1348c99bd2b0d04ce1025d3d998184e9de37b4de27abb515aa01d199" + }, + "src/fairscape_integration/constants.py": { + "bytes": 8887, + "sha256": "bba150257a445af4326f186decf18b37c0878f5d53cb84c2fa1674de2188f1e6" + }, + "src/fairscape_integration/fairscape_to_d4d.py": { + "bytes": 97645, + "sha256": "7d5f91de8c6af1712899ddfc76f5b87fa5bb6c35c7ba079e586cc6522700f6fd" + }, + "src/fairscape_integration/utils/d4d_builder.py": { + "bytes": 10025, + "sha256": "38a258d9efcf4706b98fc897af3d22c8df2570b63553945437780814469b939e" + }, + "src/fairscape_integration/utils/field_prioritizer.py": { + "bytes": 6815, + "sha256": "3cfc67a3609ed766825184bd6e1750e79e2358053960f09dcfee8279daae889e" + }, + "src/fairscape_integration/utils/informativeness_scorer.py": { + "bytes": 10968, + "sha256": "85664f12e621964869860aa45cf5080fd78806f0832156366bbee7940b64fff0" + }, + "src/fairscape_integration/utils/mapping_loader.py": { + "bytes": 6358, + "sha256": "d90341146c566ed5f03e71213a159b85ff3500cacce78e2f375bf22e4b2d2b67" + }, + "src/fairscape_integration/utils/rocrate_merger.py": { + "bytes": 12035, + "sha256": "7b7e98d48ecc1043de077c2db7342e08b216d05d9c10778a986c1b922a836b56" + }, + "src/fairscape_integration/utils/rocrate_parser.py": { + "bytes": 11273, + "sha256": "4c7a7f43a94b17893cde6917e75d4648bcf017f06f7aaa9e13f8cb92b83905ec" + } + } + }, + "candidate": { + "tested_and_replayed_commit": "180d1028bb25332ff296eafcbfb4533d3f6ee85d", + "tree": "a6d85ba51d120b1a41a74e5da4249e17a36dbf7f", + "selected_source_and_test_pins": { + ".claude/agents/scripts/d4d_builder.py": { + "bytes": 11281, + "sha256": "6b34fefa7952e84e215f23ce2031e3abf71700085c44ac53e182ae451aad2271" + }, + ".claude/agents/scripts/field_prioritizer.py": { + "bytes": 10263, + "sha256": "031d1d0f8ac000f4c9f74eff18ea268dbfa23901fe9b52e7d5b5ddba1ed99e00" + }, + ".claude/agents/scripts/informativeness_scorer.py": { + "bytes": 12331, + "sha256": "d55c17a21a3f5d38de1f88bd90c1a976a2a29751f8206a61c8731dfbee3c77bd" + }, + ".claude/agents/scripts/mapping_loader.py": { + "bytes": 7107, + "sha256": "9429e9766381d69811cc464ad3e0cc3280e455535f4d5063025d045373b56137" + }, + ".claude/agents/scripts/rocrate_merger.py": { + "bytes": 13862, + "sha256": "da41ad436c59115a5150db2b02a5455b785e82bd57735f7efdc0f55e2357ec93" + }, + ".claude/agents/scripts/rocrate_parser.py": { + "bytes": 1930, + "sha256": "c50f88eecce97c2a53e81f58f6db0af94216ceb637322f6848c260dab7f80bfd" + }, + ".claude/agents/scripts/rocrate_to_d4d.py": { + "bytes": 17099, + "sha256": "a6d299a0759f3ef7f93e73acff68325965cf98813b93885e21b3435db97b4a68" + }, + "data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv": { + "bytes": 24573, + "sha256": "01f5db8645ac0a98061a28e44b2c8eaac093cd8da2b8ea262a62460d31b27188" + }, + "notes/parser_root_policy_2026-10-07/replay.py": { + "bytes": 8793, + "sha256": "1c62c61e2cd646b23218594696ccd019989f6f8a26babc5fdbd1ccfcf351b9c2" + }, + "notes/parser_root_policy_2026-10-07/validation.json": { + "bytes": 19691, + "sha256": "efa7d37ec3999b891700d85a6a5d95eda882b958f2111e6f38a6f0d42c158efc" + }, + "poetry.lock": { + "bytes": 592155, + "sha256": "923ad962d2646e1a4be044a76ff6a66abf49c3e10ea87fc85ed3cd523a907e21" + }, + "pyproject.toml": { + "bytes": 4446, + "sha256": "bac863ac39cc39d6767627e0a7d8fd085be693555e5220015afbe6aec45c0c8b" + }, + "src/data_sheets_schema/legacy_doi.py": { + "bytes": 2148, + "sha256": "6313c18f41feb74254834e886cb55ddb608e1872494a227150b7166cd780a606" + }, + "src/data_sheets_schema/legacy_publication.py": { + "bytes": 6374, + "sha256": "faa5df37620ba6f005f7e9999ddd7653054c54808efc0988ccbd28a2a44e53a9" + }, + "src/data_sheets_schema/legacy_root_identity.py": { + "bytes": 3882, + "sha256": "d839ec3a8af433d30624f8fe02518b9e4ce149f5bb8ba0a32db0101ea9b6df93" + }, + "src/data_sheets_schema/rocrate_map.py": { + "bytes": 52876, + "sha256": "e3f3854ea2df26c5465e4ae3744f447d57efe6984262628f9e78ffe13c271592" + }, + "src/data_sheets_schema/rocrate_sources.py": { + "bytes": 11731, + "sha256": "38c85ac04a1ccdcf3382024d438f48efa577b4871bea683a562a792595907a75" + }, + "src/data_sheets_schema/schema/data_sheets_schema_all.yaml": { + "bytes": 1442513, + "sha256": "eb543e1597b29599952359818bc741b0f23eaa3a30e7aa8c63e60212c7bbb92f" + }, + "src/data_sheets_schema/scope.py": { + "bytes": 25356, + "sha256": "f586f60e1348c99bd2b0d04ce1025d3d998184e9de37b4de27abb515aa01d199" + }, + "src/fairscape_integration/cli.py": { + "bytes": 21751, + "sha256": "75ceebbe98633a768287dc260d0aa9a71d0adf211a2645a382206c0b0cb23a51" + }, + "src/fairscape_integration/constants.py": { + "bytes": 8887, + "sha256": "bba150257a445af4326f186decf18b37c0878f5d53cb84c2fa1674de2188f1e6" + }, + "src/fairscape_integration/fairscape_to_d4d.py": { + "bytes": 97645, + "sha256": "7d5f91de8c6af1712899ddfc76f5b87fa5bb6c35c7ba079e586cc6522700f6fd" + }, + "src/fairscape_integration/utils/d4d_builder.py": { + "bytes": 10466, + "sha256": "606589bdcaab93e2405d123b37b635c56dd2f04f2334c8a6cd6e47bf6a0fb0cf" + }, + "src/fairscape_integration/utils/field_prioritizer.py": { + "bytes": 6815, + "sha256": "3cfc67a3609ed766825184bd6e1750e79e2358053960f09dcfee8279daae889e" + }, + "src/fairscape_integration/utils/informativeness_scorer.py": { + "bytes": 11026, + "sha256": "952b8941a2ef26e28ddd95d5a6ce5ec09e874c169b12562dd84d32978c05e782" + }, + "src/fairscape_integration/utils/mapping_loader.py": { + "bytes": 6670, + "sha256": "d156f910b31e7176fdf09631706e22246cb41c23889af67e0a3ca830c33c70f1" + }, + "src/fairscape_integration/utils/rocrate_merger.py": { + "bytes": 13108, + "sha256": "6e9e7a36ab62b707df0323e7bf84f966c0a70853a5e3db229476f932ac75e5c4" + }, + "src/fairscape_integration/utils/rocrate_parser.py": { + "bytes": 11273, + "sha256": "4c7a7f43a94b17893cde6917e75d4648bcf017f06f7aaa9e13f8cb92b83905ec" + }, + "src/transformation/transform_api.py": { + "bytes": 34261, + "sha256": "24bd0c6f3dc92763b97a9d3d3202ac988eef88e8c15c90e394eb4704da0c0622" + }, + "tests/test_cli/test_rocrate_transform_mapping.py": { + "bytes": 8103, + "sha256": "149b9a7b75aa35bd414418134671075424b58145ceb7347f9c1ac4ec335085b6" + }, + "tests/test_cli/test_rocrate_transform_mapping_adversarial.py": { + "bytes": 4708, + "sha256": "b619461ada2b2ce8431d7f10f318c061659dd2f10656dc03e038affeb4102224" + }, + "tests/test_fairscape_integration/test_d4d_builder.py": { + "bytes": 13549, + "sha256": "696a4d829adb15aebbdcdca13d0ef5bd8f931cbfccd64ef15f311700b672efeb" + }, + "tests/test_fairscape_integration/test_informativeness_scorer.py": { + "bytes": 13782, + "sha256": "640119c5038fbede851c52d1b44d5e13e9ba892097235db0969dc3e76d96e0b9" + }, + "tests/test_fairscape_integration/test_mapping_loader.py": { + "bytes": 7924, + "sha256": "8aac068633b5d7fea3208cf529e835ef90fed306d1d97290bf629e697988f6f0" + }, + "tests/test_fairscape_integration/test_rocrate_merger.py": { + "bytes": 14479, + "sha256": "d83b2c814847f24f54dec22dd19e1247662d2a48391a3d431ef414bc6f1b5bfa" + }, + "tests/test_rocrate/test_legacy_doi.py": { + "bytes": 12579, + "sha256": "fe150f57a8505b75623b78fb66060a5104495922b3ada37d341ae5f59e9f7535" + }, + "tests/test_rocrate/test_legacy_envelope_adversarial.py": { + "bytes": 17149, + "sha256": "97fe763933dfc33e14934668840c364e4b8ab58b52f48ef57fe11e1d7f0448bd" + }, + "tests/test_rocrate/test_legacy_publication.py": { + "bytes": 19018, + "sha256": "b61058a2e214370d2f93eaaff49296bcfe410280be7b30873202713be35fa537" + }, + "tests/test_rocrate/test_legacy_publication_adversarial.py": { + "bytes": 16106, + "sha256": "b1a132a5d8ca1aa8d7a06c9c5baf6b986dc385165dc70bc2669e35aa1a5744f2" + }, + "tests/test_rocrate/test_legacy_result_contract.py": { + "bytes": 10932, + "sha256": "b95715c56bd1c7ac52eb97224fbeb8b37c69bca1e1e931264ed0acc4d0ea40e4" + }, + "tests/test_rocrate/test_legacy_root_gates.py": { + "bytes": 13090, + "sha256": "6dbc5c0ea1ee9a5fc61c42bec02eef0b4b294366bd1418e68b73fe424a6ef9f5" + }, + "tests/test_rocrate/test_legacy_root_identity.py": { + "bytes": 15986, + "sha256": "3f38acd8beab08578af6bb9ebb4f17583bbec4628678a4b436a5f75e3b8ab5b6" + }, + "tests/test_rocrate/test_legacy_root_identity_adversarial.py": { + "bytes": 11773, + "sha256": "1c4773300bf102143ef235b538fc8ead3419d4dbf5c9dc470d69aba47d964dcc" + } + }, + "pin_count": 43, + "complete_implementation_changed_paths_vs_doi_production_parent": [ + ".claude/agents/scripts/d4d_builder.py", + ".claude/agents/scripts/informativeness_scorer.py", + ".claude/agents/scripts/mapping_loader.py", + ".claude/agents/scripts/rocrate_merger.py", + ".claude/agents/scripts/rocrate_to_d4d.py", + "data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv", + "notes/legacy_root_identity_2026-10-08/README.md", + "src/data_sheets_schema/legacy_root_identity.py", + "src/fairscape_integration/cli.py", + "src/fairscape_integration/utils/d4d_builder.py", + "src/fairscape_integration/utils/informativeness_scorer.py", + "src/fairscape_integration/utils/mapping_loader.py", + "src/fairscape_integration/utils/rocrate_merger.py", + "src/transformation/transform_api.py", + "tests/test_rocrate/test_legacy_doi.py", + "tests/test_rocrate/test_legacy_root_gates.py", + "tests/test_rocrate/test_legacy_root_identity.py", + "tests/test_rocrate/test_legacy_root_identity_adversarial.py" + ], + "change_list_scope": "Complete tested Git diff versus DOI-fixed production parent. All changed source/test files are included in selected_source_and_test_pins. Comparison saved_comparison.declared_source_changes is only the narrower replay-source inventory delta." + }, + "evidence_provenance": { + "invocation": "Coordinator-recorded serialized commands and commit binding; XML does not attest a Git commit.", + "independent_author_checks": "Parsed saved JSON/XML; compared full evidence using compare_saved.py; rehashed selected current files against each recorded Git commit; checked exact copied supplement and saved artifact pins. No producers or tests rerun.", + "reviewer_checks": "Independent reviewer rehashed the complete original working-tree inventory against saved entries, including modes; verified full records, raw identity disclosures, source/resource pins and runtime. Notes are added after that preservation snapshot." + }, + "tests": { + "tests": 570, + "failures": 0, + "errors": 0, + "skipped": 0, + "seconds_xml": 45.483, + "seconds_console_coordinator": 45.52, + "dependency_deprecation_warnings_coordinator": 14, + "module_counts": { + "tests.test_rocrate.test_legacy_root_identity": 95, + "tests.test_rocrate.test_legacy_root_identity_adversarial": 42, + "tests.test_rocrate.test_legacy_doi": 155, + "tests.test_rocrate.test_legacy_root_gates": 30, + "tests.test_rocrate.test_legacy_publication": 51, + "tests.test_rocrate.test_legacy_publication_adversarial": 39, + "tests.test_rocrate.test_legacy_result_contract": 19, + "tests.test_rocrate.test_legacy_envelope_adversarial": 28, + "tests.test_cli.test_rocrate_transform_mapping": 17, + "tests.test_cli.test_rocrate_transform_mapping_adversarial": 10, + "tests.test_fairscape_integration.test_d4d_builder": 37, + "tests.test_fairscape_integration.test_mapping_loader": 15, + "tests.test_fairscape_integration.test_informativeness_scorer": 14, + "tests.test_fairscape_integration.test_rocrate_merger": 18 + }, + "command_provenance": "Coordinator-confirmed actual command, with host paths replaced by descriptive variables.", + "environment": { + "PYTHONDONTWRITEBYTECODE": "1", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONPATH": "src:." + }, + "cwd": "$CANDIDATE", + "command": [ + "$PYTHON", + "-B", + "-m", + "pytest", + "tests/test_rocrate/test_legacy_root_identity.py", + "tests/test_rocrate/test_legacy_root_identity_adversarial.py", + "tests/test_rocrate/test_legacy_doi.py", + "tests/test_rocrate/test_legacy_root_gates.py", + "tests/test_rocrate/test_legacy_publication.py", + "tests/test_rocrate/test_legacy_publication_adversarial.py", + "tests/test_rocrate/test_legacy_result_contract.py", + "tests/test_rocrate/test_legacy_envelope_adversarial.py", + "tests/test_cli/test_rocrate_transform_mapping.py", + "tests/test_cli/test_rocrate_transform_mapping_adversarial.py", + "tests/test_fairscape_integration/test_d4d_builder.py", + "tests/test_fairscape_integration/test_mapping_loader.py", + "tests/test_fairscape_integration/test_informativeness_scorer.py", + "tests/test_fairscape_integration/test_rocrate_merger.py", + "-q", + "-p", + "no:cacheprovider", + "--basetemp=$EVIDENCE/legacy-root-id-pytest-01", + "--junitxml=$EVIDENCE/legacy-root-id-tests-01.xml" + ] + }, + "runtime": { + "python": "3.13.12 | packaged by conda-forge | (main, Feb 5 2026, 06:11:05) [Clang 19.1.7 ]", + "identity": { + "bytes": 5777152, + "sha256": "e425798f1fd82ff2450b84a24b58150996345c0f8fb7f07aae68a5033ba8df19" + }, + "dependencies": { + "PyYAML": "6.0.2", + "linkml": "1.9.3", + "linkml-runtime": "1.9.4" + } + }, + "drivers": { + "unchanged_parser_replay": { + "path": "notes/parser_root_policy_2026-10-07/replay.py", + "bytes": 8793, + "sha256": "1c62c61e2cd646b23218594696ccd019989f6f8a26babc5fdbd1ccfcf351b9c2" + }, + "supplement": { + "path": "notes/legacy_root_identity_2026-10-08/replay_supplement.py", + "bytes": 17043, + "sha256": "a45dc2e339999d5ca127d2f2a6575bad8ffafe44ee08269c97d9cf0292ee5b6d" + }, + "saved_only_comparator": { + "path": "notes/legacy_root_identity_2026-10-08/compare_saved.py", + "bytes": 17054, + "sha256": "74d9256298c851b15836ac1ea8fa97e583e16cd21171c81c7f33fa17f9c16877" + } + }, + "artifacts": { + "legacy-root-id-tests-01.xml": { + "bytes": 93360, + "sha256": "7a11b05f7831f0cf99de06bbfdcbf33236a35b9cb2f30f244518cc2a3869ee1b" + }, + "legacy-root-id-baseline-replay-01/summary.json": { + "bytes": 245777, + "sha256": "50c0f1e9475abebbe31e195c34c985bb4aa14529b25b4f36156806b05ac2cd35" + }, + "legacy-root-id-candidate-replay-01/summary.json": { + "bytes": 244729, + "sha256": "9a6684d4bfefde833d5b3e225d2e42bcbb0bae6344c9475cb6e0ad7a9d774541" + }, + "legacy-root-id-baseline-supplement-01/summary.json": { + "bytes": 2143054, + "sha256": "b85d48c594e941ace663d30b2132cd0953aa00597c9867c75054c51de057f312" + }, + "legacy-root-id-candidate-supplement-01/summary.json": { + "bytes": 2170108, + "sha256": "7c197b67e7b91d7e5742bf1e480cb27adcf4d450fe23c54a5e6d96c515210eb4" + }, + "legacy-root-id-saved-comparison-02.json": { + "bytes": 20446, + "sha256": "5aa3c5e96f225655510c85a5e7b03b77245a3df15daa99a205a009877d67b842" + }, + "legacy-root-id-preservation-before.json": { + "bytes": 4358251, + "sha256": "6a4d05992df40fa97145b5b8ff68509d3eff474cff1a5a7e5793db8876673529" + }, + "legacy-root-id-preservation-after.json": { + "bytes": 4358251, + "sha256": "6a4d05992df40fa97145b5b8ff68509d3eff474cff1a5a7e5793db8876673529" + } + }, + "full_checkout_preservation": { + "scope": "All actual files, including hidden and ignored entries, excluding .git; byte identities and modes. Notes below are added after the snapshot.", + "before_after_bytes_equal": true, + "manifest": { + "bytes": 4358251, + "sha256": "6a4d05992df40fa97145b5b8ff68509d3eff474cff1a5a7e5793db8876673529" + }, + "arms": { + "baseline": { + "files": 9278, + "total_file_bytes": 447041975, + "commit": "f2f1b3f465fa1a06adf150285d2ec1e9083a7839", + "tree": "94882d696404a25dc3dee83d1dacab4b957a1956", + "zip_member": { + "bytes": 12242926, + "path": "cm4ai_release_metadata/ro-crate-metadata.json", + "sha256": "836376f3a997204b14d4d8d3fba90d967b58cbe20726218c95d861b0cb11e66e" + } + }, + "candidate": { + "files": 9281, + "total_file_bytes": 447072243, + "commit": "180d1028bb25332ff296eafcbfb4533d3f6ee85d", + "tree": "a6d85ba51d120b1a41a74e5da4249e17a36dbf7f", + "zip_member": { + "bytes": 12242926, + "path": "cm4ai_release_metadata/ro-crate-metadata.json", + "sha256": "836376f3a997204b14d4d8d3fba90d967b58cbe20726218c95d861b0cb11e66e" + } + } + } + }, + "selected_preservation": { + "baseline_files": 79, + "candidate_files": 80, + "saved_replay_files_per_arm": 71, + "equal_before_after": true + }, + "results": { + "record_pairs": 20, + "ranking_pairs": 4, + "merge_pairs": 8, + "all_original_reversed_and_implementation_comparisons_equal": true, + "baseline_record_gates": { + "accepted": 0, + "refused": 20 + }, + "candidate_record_gates": { + "accepted": 4, + "refused": 16 + }, + "mixed_merge_gates_per_arm": { + "accepted": 0, + "refused": 8 + }, + "gate_denominator_per_arm": 28, + "remaining_errors_per_record": { + "CHORUS": { + "baseline": 32, + "candidate": 31 + }, + "VOICE": { + "baseline": 152, + "candidate": 151 + }, + "CM4AI_reduced": { + "baseline": 77, + "candidate": 76 + }, + "VOICE_provenance": { + "baseline": 1, + "candidate": 0 + }, + "CM4AI_original": { + "baseline": 77, + "candidate": 76 + } + }, + "new_ids": { + "CHORUS": "doi:10.18130/V3/XNBOPG", + "VOICE": "doi:10.13026/k81f-qr68", + "CM4AI_reduced": "doi:10.18130/V3/HIGT4C", + "VOICE_provenance": "ark:59853/b2ai-voice-dataset-feature-ppgs", + "CM4AI_original": "doi:10.18130/V3/HIGT4C" + }, + "ranking": [ + { + "source": "VOICE", + "index": 1, + "scores": { + "d4d_coverage": 34, + "unique_fields": 9, + "metadata_richness": 0.25, + "technical_completeness": 0.25, + "total_score": 0.3223129992737836 + }, + "rank": 1 + }, + { + "source": "CM4AI_reduced", + "index": 2, + "scores": { + "d4d_coverage": 27, + "unique_fields": 0, + "metadata_richness": 0.5, + "technical_completeness": 0.25, + "total_score": 0.25833333333333336 + }, + "rank": 2 + }, + { + "source": "CM4AI_original", + "index": 4, + "scores": { + "d4d_coverage": 27, + "unique_fields": 0, + "metadata_richness": 0.5, + "technical_completeness": 0.25, + "total_score": 0.25833333333333336 + }, + "rank": 3 + }, + { + "source": "CHORUS", + "index": 0, + "scores": { + "d4d_coverage": 21, + "unique_fields": 1, + "metadata_richness": 0.25, + "technical_completeness": 0.5, + "total_score": 0.21798941798941796 + }, + "rank": 4 + }, + { + "source": "VOICE_provenance", + "index": 3, + "scores": { + "d4d_coverage": 3, + "unique_fields": 1, + "metadata_richness": 0.25, + "technical_completeness": 0.0, + "total_score": 0.1648148148148148 + }, + "rank": 5 + } + ], + "ranked_primary": "VOICE", + "explicit_fallback_primary": "VOICE_provenance", + "construction_statistics_delta": { + "fields_from_primary": 1, + "total_unique_fields": 1, + "all_other_statistics": 0 + }, + "non_id_full_records_roots_provenance_unchanged": true, + "all_non_id_validation_errors_retained": true, + "merged_identity_disclosures_checked": 40, + "accepted_record_prepared_byte_pin": { + "status": "accepted", + "bytes": 739, + "sha256": "79f89f7e0c544997d137d8dac96ff74f2a80cbd8fa72ebc3815cf2186c64fa42" + }, + "saved_comparison": { + "format": "legacy_root_identity_comparison_v1", + "verified": true, + "artifacts": { + "baseline": { + "supplement": { + "bytes": 2143054, + "sha256": "b85d48c594e941ace663d30b2132cd0953aa00597c9867c75054c51de057f312" + }, + "replay": { + "bytes": 245777, + "sha256": "50c0f1e9475abebbe31e195c34c985bb4aa14529b25b4f36156806b05ac2cd35" + } + }, + "candidate": { + "supplement": { + "bytes": 2170108, + "sha256": "7c197b67e7b91d7e5742bf1e480cb27adcf4d450fe23c54a5e6d96c515210eb4" + }, + "replay": { + "bytes": 244729, + "sha256": "9a6684d4bfefde833d5b3e225d2e42bcbb0bae6344c9475cb6e0ad7a9d774541" + } + } + }, + "commits": { + "baseline": "f2f1b3f465fa1a06adf150285d2ec1e9083a7839", + "candidate": "180d1028bb25332ff296eafcbfb4533d3f6ee85d" + }, + "denominators": { + "record_pairs": 20, + "ranking_pairs": 4, + "merge_pairs": 8, + "gates_per_arm": 28 + }, + "records": [ + { + "input": "CHORUS", + "implementation": "packaged", + "order": "original", + "id": "doi:10.18130/V3/XNBOPG", + "baseline_errors": 32, + "candidate_errors": 31, + "remaining_errors_sha256": "e0a63ab6b125749b9b38e358ea6dbf24b8fc935353a3633ae2b37dc086a4763b", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE", + "implementation": "packaged", + "order": "original", + "id": "doi:10.13026/k81f-qr68", + "baseline_errors": 152, + "candidate_errors": 151, + "remaining_errors_sha256": "eb01de42fbff655ebca7bf1f2e5abc4cd5ddaacc657aa87fc05b4f2d1b0fe321", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CM4AI_reduced", + "implementation": "packaged", + "order": "original", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE_provenance", + "implementation": "packaged", + "order": "original", + "id": "ark:59853/b2ai-voice-dataset-feature-ppgs", + "baseline_errors": 1, + "candidate_errors": 0, + "remaining_errors_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "baseline_gate": "refused", + "candidate_gate": "accepted" + }, + { + "input": "CM4AI_original", + "implementation": "packaged", + "order": "original", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CHORUS", + "implementation": "packaged", + "order": "reversed", + "id": "doi:10.18130/V3/XNBOPG", + "baseline_errors": 32, + "candidate_errors": 31, + "remaining_errors_sha256": "e0a63ab6b125749b9b38e358ea6dbf24b8fc935353a3633ae2b37dc086a4763b", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE", + "implementation": "packaged", + "order": "reversed", + "id": "doi:10.13026/k81f-qr68", + "baseline_errors": 152, + "candidate_errors": 151, + "remaining_errors_sha256": "eb01de42fbff655ebca7bf1f2e5abc4cd5ddaacc657aa87fc05b4f2d1b0fe321", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CM4AI_reduced", + "implementation": "packaged", + "order": "reversed", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE_provenance", + "implementation": "packaged", + "order": "reversed", + "id": "ark:59853/b2ai-voice-dataset-feature-ppgs", + "baseline_errors": 1, + "candidate_errors": 0, + "remaining_errors_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "baseline_gate": "refused", + "candidate_gate": "accepted" + }, + { + "input": "CM4AI_original", + "implementation": "packaged", + "order": "reversed", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CHORUS", + "implementation": "hidden", + "order": "original", + "id": "doi:10.18130/V3/XNBOPG", + "baseline_errors": 32, + "candidate_errors": 31, + "remaining_errors_sha256": "e0a63ab6b125749b9b38e358ea6dbf24b8fc935353a3633ae2b37dc086a4763b", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE", + "implementation": "hidden", + "order": "original", + "id": "doi:10.13026/k81f-qr68", + "baseline_errors": 152, + "candidate_errors": 151, + "remaining_errors_sha256": "eb01de42fbff655ebca7bf1f2e5abc4cd5ddaacc657aa87fc05b4f2d1b0fe321", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CM4AI_reduced", + "implementation": "hidden", + "order": "original", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE_provenance", + "implementation": "hidden", + "order": "original", + "id": "ark:59853/b2ai-voice-dataset-feature-ppgs", + "baseline_errors": 1, + "candidate_errors": 0, + "remaining_errors_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "baseline_gate": "refused", + "candidate_gate": "accepted" + }, + { + "input": "CM4AI_original", + "implementation": "hidden", + "order": "original", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CHORUS", + "implementation": "hidden", + "order": "reversed", + "id": "doi:10.18130/V3/XNBOPG", + "baseline_errors": 32, + "candidate_errors": 31, + "remaining_errors_sha256": "e0a63ab6b125749b9b38e358ea6dbf24b8fc935353a3633ae2b37dc086a4763b", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE", + "implementation": "hidden", + "order": "reversed", + "id": "doi:10.13026/k81f-qr68", + "baseline_errors": 152, + "candidate_errors": 151, + "remaining_errors_sha256": "eb01de42fbff655ebca7bf1f2e5abc4cd5ddaacc657aa87fc05b4f2d1b0fe321", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "CM4AI_reduced", + "implementation": "hidden", + "order": "reversed", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + }, + { + "input": "VOICE_provenance", + "implementation": "hidden", + "order": "reversed", + "id": "ark:59853/b2ai-voice-dataset-feature-ppgs", + "baseline_errors": 1, + "candidate_errors": 0, + "remaining_errors_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "baseline_gate": "refused", + "candidate_gate": "accepted" + }, + { + "input": "CM4AI_original", + "implementation": "hidden", + "order": "reversed", + "id": "doi:10.18130/V3/HIGT4C", + "baseline_errors": 77, + "candidate_errors": 76, + "remaining_errors_sha256": "c8dc6c5bd374586c957c66998cef3af321236b65d048e31c874a9019dbfb4242", + "baseline_gate": "refused", + "candidate_gate": "refused" + } + ], + "ranking": [ + { + "source": "VOICE", + "index": 1, + "scores": { + "d4d_coverage": 34, + "unique_fields": 9, + "metadata_richness": 0.25, + "technical_completeness": 0.25, + "total_score": 0.3223129992737836 + }, + "rank": 1 + }, + { + "source": "CM4AI_reduced", + "index": 2, + "scores": { + "d4d_coverage": 27, + "unique_fields": 0, + "metadata_richness": 0.5, + "technical_completeness": 0.25, + "total_score": 0.25833333333333336 + }, + "rank": 2 + }, + { + "source": "CM4AI_original", + "index": 4, + "scores": { + "d4d_coverage": 27, + "unique_fields": 0, + "metadata_richness": 0.5, + "technical_completeness": 0.25, + "total_score": 0.25833333333333336 + }, + "rank": 3 + }, + { + "source": "CHORUS", + "index": 0, + "scores": { + "d4d_coverage": 21, + "unique_fields": 1, + "metadata_richness": 0.25, + "technical_completeness": 0.5, + "total_score": 0.21798941798941796 + }, + "rank": 4 + }, + { + "source": "VOICE_provenance", + "index": 3, + "scores": { + "d4d_coverage": 3, + "unique_fields": 1, + "metadata_richness": 0.25, + "technical_completeness": 0.0, + "total_score": 0.1648148148148148 + }, + "rank": 5 + } + ], + "merges": [ + { + "implementation": "packaged", + "order": "original", + "mode": "ranked_primary", + "primary": "VOICE", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 25, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 26, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "packaged_original_ranked_primary.txt", + "bytes": 2765, + "sha256": "328c9f49a5d09decd3778c2caca30ddb2512a24b8a4138d3c045940c341baacb" + }, + "candidate_report": { + "path": "packaged_original_ranked_primary.txt", + "bytes": 5619, + "sha256": "4710c4d4552ec5533c541582b5f66d34e057b608bcc2ade86b8e92a039d1357d" + }, + "gate": "refused", + "remaining_gate_sha256": "c7e6b23ff2687d2189d06ab202a92f2de4285d993c3eb720adc74d9991db5849" + }, + { + "implementation": "packaged", + "order": "original", + "mode": "voice_provenance_primary", + "primary": "VOICE_provenance", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 2, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 3, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "packaged_original_voice_provenance_primary.txt", + "bytes": 2791, + "sha256": "7518c0c63bf66319808c0b653df597e8ef8908bb45c459d01d2599989b98320c" + }, + "candidate_report": { + "path": "packaged_original_voice_provenance_primary.txt", + "bytes": 5656, + "sha256": "70c4ef0d9617686aab3bef21ab952045f4f896a12e1d4ccf8c05f1acec7abda4" + }, + "gate": "refused", + "remaining_gate_sha256": "3f769fdf2a04b8247e19ff6826b50db31b2f5669b20532187b32256909e20469" + }, + { + "implementation": "packaged", + "order": "reversed", + "mode": "ranked_primary", + "primary": "VOICE", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 25, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 26, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "packaged_reversed_ranked_primary.txt", + "bytes": 2765, + "sha256": "d43dc3d92536144e9eca62afe64f30d4804865e0614669320f6a6e4a2938e1b8" + }, + "candidate_report": { + "path": "packaged_reversed_ranked_primary.txt", + "bytes": 5664, + "sha256": "dbe4be77c1c9bf70c1d5e7073a17c7d9226967e3136f18323c992c0c4c57abff" + }, + "gate": "refused", + "remaining_gate_sha256": "c7e6b23ff2687d2189d06ab202a92f2de4285d993c3eb720adc74d9991db5849" + }, + { + "implementation": "packaged", + "order": "reversed", + "mode": "voice_provenance_primary", + "primary": "VOICE_provenance", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 2, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 3, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "packaged_reversed_voice_provenance_primary.txt", + "bytes": 2791, + "sha256": "2f6fb12b61a1e47a669819951bcfab9f99aeb2c01e1651353a7552719d9029b7" + }, + "candidate_report": { + "path": "packaged_reversed_voice_provenance_primary.txt", + "bytes": 5701, + "sha256": "bdf9b42a03a05599b0e4e2abe56353135e75c097ad0e96a9431dab87baf8b950" + }, + "gate": "refused", + "remaining_gate_sha256": "3f769fdf2a04b8247e19ff6826b50db31b2f5669b20532187b32256909e20469" + }, + { + "implementation": "hidden", + "order": "original", + "mode": "ranked_primary", + "primary": "VOICE", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 25, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 26, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "hidden_original_ranked_primary.txt", + "bytes": 2765, + "sha256": "c670e5567c737c101754e55e8877e57f5026457948ea4462079f17f77e1758ea" + }, + "candidate_report": { + "path": "hidden_original_ranked_primary.txt", + "bytes": 5619, + "sha256": "633554298820882b7260e727e59e29c6c047dba3eaa21fba81c1642267763bcb" + }, + "gate": "refused", + "remaining_gate_sha256": "c7e6b23ff2687d2189d06ab202a92f2de4285d993c3eb720adc74d9991db5849" + }, + { + "implementation": "hidden", + "order": "original", + "mode": "voice_provenance_primary", + "primary": "VOICE_provenance", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 2, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 3, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "hidden_original_voice_provenance_primary.txt", + "bytes": 2791, + "sha256": "a71c3e0c7573099e81b8d1eca0a80aee9daa058ffb73663b413666d30f7fa482" + }, + "candidate_report": { + "path": "hidden_original_voice_provenance_primary.txt", + "bytes": 5656, + "sha256": "f0a0eea399a09706aec99907d62fc67c0384654694c401c29f1675bd8f89db85" + }, + "gate": "refused", + "remaining_gate_sha256": "3f769fdf2a04b8247e19ff6826b50db31b2f5669b20532187b32256909e20469" + }, + { + "implementation": "hidden", + "order": "reversed", + "mode": "ranked_primary", + "primary": "VOICE", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 25, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 26, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "hidden_reversed_ranked_primary.txt", + "bytes": 2765, + "sha256": "eda5382b67d0a3c1016eec3b6402cb49202f6bcb61dcfad63b104e7c792dd807" + }, + "candidate_report": { + "path": "hidden_reversed_ranked_primary.txt", + "bytes": 5664, + "sha256": "0022d6a427cbee0ecdc79fe0df5697e4570f058f4a29db0fb3cb33b5d56bfd53" + }, + "gate": "refused", + "remaining_gate_sha256": "c7e6b23ff2687d2189d06ab202a92f2de4285d993c3eb720adc74d9991db5849" + }, + { + "implementation": "hidden", + "order": "reversed", + "mode": "voice_provenance_primary", + "primary": "VOICE_provenance", + "baseline_statistics": { + "total_sources": 5, + "fields_from_primary": 2, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 39 + }, + "candidate_statistics": { + "total_sources": 5, + "fields_from_primary": 3, + "fields_from_secondary": 3, + "fields_combined": 2, + "fields_merged_as_arrays": 6, + "total_unique_fields": 40 + }, + "baseline_report": { + "path": "hidden_reversed_voice_provenance_primary.txt", + "bytes": 2791, + "sha256": "798aa869b8e54eda5d6d48f821c2475fcb0a942cedbc28ef412270447214cfa4" + }, + "candidate_report": { + "path": "hidden_reversed_voice_provenance_primary.txt", + "bytes": 5701, + "sha256": "996ec22e134fd3f025310073febba8b37630d3dfa298d99329580994a45732d7" + }, + "gate": "refused", + "remaining_gate_sha256": "3f769fdf2a04b8247e19ff6826b50db31b2f5669b20532187b32256909e20469" + } + ], + "declared_source_changes": [ + ".claude/agents/scripts/d4d_builder.py", + ".claude/agents/scripts/informativeness_scorer.py", + ".claude/agents/scripts/mapping_loader.py", + ".claude/agents/scripts/rocrate_merger.py", + "data/ro-crate_mapping/d4d_rocrate_mapping_v2_semantic.tsv", + "src/fairscape_integration/utils/d4d_builder.py", + "src/fairscape_integration/utils/informativeness_scorer.py", + "src/fairscape_integration/utils/mapping_loader.py", + "src/fairscape_integration/utils/rocrate_merger.py" + ], + "new_source_declarations": [ + "src/data_sheets_schema/legacy_root_identity.py" + ], + "scientific_eligibility": false, + "general_legacy_validity": false, + "limits": [ + "Saved-evidence comparison; no producer, schema validator, scorer or merger executed here.", + "Source/runtime declarations are compared, not reauthenticated from their original paths.", + "Accepted Dataset-byte pins are saved producer observations; full records and preserved validation errors are compared directly.", + "Report normalization permits only observed timestamp, the separately verified ID disclosure, and explicit construction-count deltas.", + "No source-coverage, entity-equivalence, scientific-accuracy or general publication-validity conclusion." + ] + } + }, + "warnings": { + "optional_fairscape_model_unavailable": "Coordinator observed optional model-import warnings during historical replay; actual legacy builder/parser/gate runs completed. This does not establish availability of the optional FAIRSCAPE integration.", + "test_warnings": "14 dependency deprecation warnings are coordinator console evidence; they are not encoded as a JUnit warning count." + }, + "claims": { + "scientific_eligibility": false, + "source_coverage_gain": false, + "entity_equivalence": false, + "general_legacy_validity": false, + "historical_outputs_republished": false + }, + "limitations": [ + "Per-arm collection completion is distinct from cross-arm equivalence and Dataset validity.", + "Four accepted records are the two implementations and two graph orders of one VOICE provenance input; they are not four independent datasets.", + "All four other inputs still fail mandatory Dataset validation; all eight mixed-roster merges per arm also fail.", + "Ranked-primary merges retain fixed secondary order; this is not a full auto-reordered CLI replay.", + "Mixed-roster merges test software behavior only and do not describe a scientific combined dataset.", + "Construction field-count increments are not improved source coverage. No rows were retired; literal preservation is not exactMatch or none.", + "Accepted gate bytes were prepared and hashed in memory, not published; the saved YAML draft uses a different serialization. Full accepted records compare exactly, but prepared byte contents were not separately retained.", + "Saved comparator executes no repository code and cannot authenticate execution. Selected dependency versions and executable bytes are not a complete environment or import-cache snapshot.", + "Full drafts, raw source values, report paths and full preservation inventories stay external. Existing parser and DOI evidence remains unchanged.", + "Parent mapping and publication obligations under #4594/#2915, and pending human scientific reviews, remain open." + ] +}