diff --git a/docs/standards/coverage.md b/docs/standards/coverage.md index 3692d91..57d4f00 100644 --- a/docs/standards/coverage.md +++ b/docs/standards/coverage.md @@ -3,7 +3,7 @@ Generated from [requirements.json](requirements.json) by `python scripts/check_requirements.py --render`. The default command checks this table without modifying it. -49 supported requirements; 5 explicit profile exclusions; 905 distinct collected tests; 1034 requirement-to-test links. +50 supported requirements; 5 explicit profile exclusions; 907 distinct collected tests; 1036 requirement-to-test links. Counts describe collected evidence, not executed or passing tests. One test can support several rows. This is not a percentage of all clauses in the copied specifications or a complete Cartesian test matrix. @@ -74,6 +74,7 @@ harness skips are reported by pytest when executing the suite, not hidden by col | [WL-IDENTITY](#wl-identity) | policy / supported | WL relabelling returns a fresh isomorphic quad list, preserves blank-node cardinality and leaves input quads untouched. | 10 | | [WL-COMPONENTS](#wl-components) | policy / supported | Unrelated additions, edits and removals do not advance a disconnected component's default refinement, including an added class in a complete generated-schema document. | 11 | | [WL-TIES](#wl-ties) | policy / supported | Structurally tied nodes remain injective and collision suffixes follow numeric canonical numbering. | 4 | +| [WL-LABEL-FORM](#wl-label-form) | policy / supported | Generated blank-node labels have the documented form: 'b' followed by 12 hex digits, with an optional numeric collision suffix. | 2 | | [WL-GRAPHS](#wl-graphs) | policy / supported | Named graphs influence labels; blank graph-name identifiers remain consistent when also used as object terms. | 4 | | [WL-ITERATIONS](#wl-iterations) | policy / supported | Default refinement reaches component fixpoints, explicit rounds remain synchronous, zero is valid and negative counts are refused. | 7 | | [WL-BOUNDARY](#wl-boundary) | policy / supported | Embedded triple objects are rejected consistently at zero, explicit and default refinement rounds. | 33 | @@ -912,6 +913,26 @@ Project contract; no normative standard algorithm is claimed. | subprocess | Not applicable: This row has no process-state-specific obligation; process determinism is mapped separately. | | end-to-end | Not applicable: This row isolates a contract dimension; executable composed workflows are mapped in API-EXAMPLES. | +### WL-LABEL-FORM + +Generated blank-node labels have the documented form: 'b' followed by 12 hex digits, with an optional numeric collision suffix. + +Features: `wl_blank_node_labels`, `wl_relabel_quads`. + +Project contract; no normative standard algorithm is claimed. + +- local: [`diffable_rdf.wl:wl_blank_node_labels`](../../src/diffable_rdf/wl.py). +- local: [`diffable_rdf.wl:wl_relabel_quads`](../../src/diffable_rdf/wl.py). + +| Dimension | Collected evidence or applicability | +| --- | --- | +| positive | `tests/wl/test_wl_contracts.py::test_labels_have_the_documented_form` (1) | +| negative | Not applicable: This is a transformation of admitted values, not a syntax parser or a new input-rejection API. | +| boundary | `tests/wl/test_wl_contracts.py::test_tied_labels_share_one_base_and_add_numeric_suffixes` (1) | +| property | Not applicable: No independent randomized property is claimed for this row; examples establish only the stated dimensions. | +| subprocess | Not applicable: This row has no process-state-specific obligation; process determinism is mapped separately. | +| end-to-end | Not applicable: This row isolates the label form; composed documents are mapped in RDF-ISOMORPHISM and WL-COMPONENTS. | + ### WL-GRAPHS Named graphs influence labels; blank graph-name identifiers remain consistent when also used as object terms. diff --git a/docs/standards/requirements.json b/docs/standards/requirements.json index a16559e..bd330ab 100644 --- a/docs/standards/requirements.json +++ b/docs/standards/requirements.json @@ -2120,6 +2120,44 @@ "end-to-end": "This row isolates a contract dimension; executable composed workflows are mapped in API-EXAMPLES." } }, + { + "id": "WL-LABEL-FORM", + "description": "Generated blank-node labels have the documented form: 'b' followed by 12 hex digits, with an optional numeric collision suffix.", + "features": [ + "wl_blank_node_labels", + "wl_relabel_quads" + ], + "category": "policy", + "clauses": [], + "owners": [ + { + "role": "local", + "target": "diffable_rdf.wl:wl_blank_node_labels", + "path": "src/diffable_rdf/wl.py" + }, + { + "role": "local", + "target": "diffable_rdf.wl:wl_relabel_quads", + "path": "src/diffable_rdf/wl.py" + } + ], + "status": "supported", + "reason": null, + "evidence": { + "positive": [ + "tests/wl/test_wl_contracts.py::test_labels_have_the_documented_form" + ], + "boundary": [ + "tests/wl/test_wl_contracts.py::test_tied_labels_share_one_base_and_add_numeric_suffixes" + ] + }, + "not_applicable": { + "negative": "This is a transformation of admitted values, not a syntax parser or a new input-rejection API.", + "property": "No independent randomized property is claimed for this row; examples establish only the stated dimensions.", + "subprocess": "This row has no process-state-specific obligation; process determinism is mapped separately.", + "end-to-end": "This row isolates the label form; composed documents are mapped in RDF-ISOMORPHISM and WL-COMPONENTS." + } + }, { "id": "WL-GRAPHS", "description": "Named graphs influence labels; blank graph-name identifiers remain consistent when also used as object terms.", diff --git a/tests/integration/test_naturalistic_schema.py b/tests/integration/test_naturalistic_schema.py index 4417e03..a2909d7 100644 --- a/tests/integration/test_naturalistic_schema.py +++ b/tests/integration/test_naturalistic_schema.py @@ -149,7 +149,7 @@ def rename(term): def _reordered(graph: Graph) -> Graph: - """The same graph with its triples inserted in the opposite order.""" + """The same graph with its triples inserted in descending term order.""" reordered = Graph() for prefix, namespace in graph.namespaces(): reordered.bind(prefix, namespace) @@ -171,6 +171,11 @@ def _renderings() -> list: ] +def _render_only() -> list: + """The same renderings, for checks that never re-parse the output.""" + return [pytest.param(case.values[0], id=case.id) for case in _renderings()] + + def test_fixture_contains_the_shapes_the_other_tests_rely_on() -> None: """Guard the fixture: every hard shape must really be present. @@ -218,14 +223,14 @@ def test_every_rendering_is_idempotent(render, parse_format: str) -> None: assert second == first -@pytest.mark.parametrize(("render", "parse_format"), _renderings()) -def test_every_rendering_ignores_incoming_blank_node_names(render, parse_format: str) -> None: +@pytest.mark.parametrize("render", _render_only()) +def test_every_rendering_ignores_incoming_blank_node_names(render) -> None: """Renaming every blank node must not change a single byte.""" assert render(_relabelled(_schema_graph())) == render(_schema_graph()) -@pytest.mark.parametrize(("render", "parse_format"), _renderings()) -def test_every_rendering_ignores_insertion_order(render, parse_format: str) -> None: +@pytest.mark.parametrize("render", _render_only()) +def test_every_rendering_ignores_insertion_order(render) -> None: """Inserting the same triples in another order must not change a single byte.""" assert render(_reordered(_schema_graph())) == render(_schema_graph()) diff --git a/tests/wl/test_wl_contracts.py b/tests/wl/test_wl_contracts.py index 2da3d7b..7436574 100644 --- a/tests/wl/test_wl_contracts.py +++ b/tests/wl/test_wl_contracts.py @@ -2,9 +2,11 @@ from __future__ import annotations +import re + import pyoxigraph as ox import pytest -from rdflib import BNode, Graph, Namespace +from rdflib import BNode, Graph, Literal, Namespace from rdflib.compare import isomorphic from diffable_rdf import wl_blank_node_labels, wl_relabel_quads @@ -299,3 +301,68 @@ def test_wl_fixpoint_resolves_collisions_that_few_rounds_leave_behind() -> None: assert any("_" in label for label in four_rounds.values()), "four iterations leave tied chain nodes" assert all("_" not in label for label in fixpoint.values()), "fixpoint labels require no collision suffix" + + +#: The label form documented in docs/api.md: ``b`` followed by 12 hex digits, +#: with an optional numeric collision suffix. +_LABEL_FORM = re.compile(r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?") + + +def _distinct_blank_nodes(count: int) -> Graph: + """A graph whose blank nodes are all structurally distinguishable.""" + graph = Graph() + graph.bind("ex", EX) + for index in range(count): + node = BNode() + graph.add((EX.root, EX.has, node)) + graph.add((node, EX.position, Literal(index))) + return graph + + +def _tied_blank_nodes(names: tuple[str, ...]) -> list: + """One structurally indistinguishable blank node per input name.""" + quads = [] + for name in names: + node = ox.BlankNode(name) + quads.append(ox.Quad(ox.NamedNode("http://ex/root"), ox.NamedNode("http://ex/has"), node, ox.DefaultGraph())) + quads.append(ox.Quad(node, ox.NamedNode("http://ex/kind"), ox.Literal("same"), ox.DefaultGraph())) + return quads + + +def test_labels_have_the_documented_form() -> None: + """Every label is ``b`` plus exactly 12 hex digits. + + This form is documented in docs/api.md, and consumers match on it rather than + merely checking that labels changed: linkml asserts this exact shape in three + separate modules to confirm its ``--diff-stable`` flag reached the serializer. + Widening the hash or changing the prefix therefore breaks them, so the form is + pinned here instead of being discovered downstream. + """ + labels = wl_blank_node_labels(_canonical_quads(_distinct_blank_nodes(4))) + + assert len(labels) == 4 + assert len(set(labels.values())) == 4 + for label in labels.values(): + assert _LABEL_FORM.fullmatch(label), label + assert len(label) == 13, "an untied label carries no collision suffix" + + +def test_tied_labels_share_one_base_and_add_numeric_suffixes() -> None: + """Tied nodes keep the documented base form and differ only by the suffix.""" + names = ("c14n0", "c14n1", "c14n2") + quads = _tied_blank_nodes(names) + + labels = wl_blank_node_labels(quads) + relabelled = { + term.value + for quad in wl_relabel_quads(quads) + for term in (quad.subject, quad.object) + if isinstance(term, ox.BlankNode) + } + + assert len(set(labels.values())) == len(names), "labels must stay injective" + assert set(labels.values()) == relabelled, "relabelled quads carry the reported labels" + assert len({label.partition("_")[0] for label in labels.values()}) == 1, "tied nodes share one base" + for label in labels.values(): + assert _LABEL_FORM.fullmatch(label), label + assert sorted(label.partition("_")[2] for label in labels.values()) == ["", "1", "2"]