Skip to content

Require breadcrumb item on every entry but the last - #38

Merged
mauanga merged 2 commits into
mainfrom
breadcrumb-last-entry
Sep 18, 2026
Merged

mauanga merged 2 commits into
mainfrom
breadcrumb-last-entry

Conversation

@mauanga

@mauanga mauanga commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Google requires item on every breadcrumb entry except the last, which falls back to the page URL. We only enforced "at most one entry may omit it", so a trail missing item on its first or middle entry validated clean.

Fix

Two sh:sparql constraints on the BreadcrumbList shape replace that count rule:

  • Item order — an entry may omit item only if it holds the highest schema:position uniquely. Core SHACL can't compare a node against its siblings, and RDF has no array order, so "last" only exists as "highest position". Ties rank, so a trail with no clear last entry reports every entry that omits item.
  • Position — reports any position the order constraint could not rank, so an unrankable position can never silently exempt the entry beside it.

Positions compare as xsd:double, so 2, "2" and 2.0 match and "9" ranks below "10". The two constraints are disjoint — the order rule only fires when the cast succeeds, the position rule only when it doesn't — so one defect never draws two messages. Carousels are untouched.

position is checked only for rankability, not for Google's Integer type, so 2.5 and INF pass. No google-*.ttl shape carries datatype constraints today; generating them from Google's Type column is the wider fix.

Cost

Roughly 4x against the breadcrumb shape alone: 10 → 40 ms for a 6-entry trail, 26 → 104 ms at 20 entries, 63 → 211 ms at 50.

@mauanga
mauanga force-pushed the breadcrumb-last-entry branch from d8277f9 to 2c0c241 Compare September 14, 2026 23:13
@mauanga
mauanga requested a review from rpanfili September 15, 2026 07:20
@mauanga
mauanga marked this pull request as ready for review September 15, 2026 08:43
# entry; sameTerm keeps a literal entry from raising a type error. Positions cast to
# xsd:double: unparseable forms raise and NaN loses every comparison, so those
# entries are accepted — a gap pinned in the tests.
_BREADCRUMB_ITEM_ORDER_SELECT = f"""SELECT $this ?value ?path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new SPARQL rule is a real improvement (it catches "the omitted entry isn't actually last", which the old qualifiedMaxCount rule couldn't), but it also drops that rule's one unconditional guarantee: qualifiedMaxCount 1 counted item-less entries without needing position to be parseable, so it always caught [{"position": "a"}, {"position": "b"}] — two entries, both missing item. The new rule can't flag that case, since neither "a" nor "b" casts to xsd:double, so nothing ranks and nothing gets reported. That's pinned by test_known_limitation_unrankable_positions_are_accepted (non-numeric-positions-two-omit), so it's a regression versus the pre-PR behavior, not just a documented gap.

Since SHACL constraints on one shape are ANDed, keeping the old qualifiedMaxCount 1 property shape alongside the new sh:sparql one closes this without touching the ordering fix:

:google_BreadcrumbListShape
  ...
  sh:property [
    sh:path schema:itemListElement ;
    sh:qualifiedValueShape [ sh:not [ sh:property [ sh:path schema:item ; sh:minCount 1 ; ] ; ] ; ] ;
    sh:qualifiedMaxCount 1 ;
    sh:message "Required by Google: at most one entry may omit item." ;
  ] ;
  sh:sparql :google_BreadcrumbListItemOrderConstraint ;
.

In generator.py, that means keeping _emit_breadcrumb_item_exemption alongside the new _emit_breadcrumb_item_order, both gated on the same emit_breadcrumb_item_order flag, instead of deleting the former. Two-or-more omissions then fail on the count rule regardless of whether position parses; a single non-last omission still fails on the SPARQL rule. Would also move non-numeric-positions-two-omit from the "known limitation" test into the "fails" group.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would push back here. I deliberately dropped the previous behavior. It wasn't asserting anything real - "at most one item missing" isn't a mandate. The Google documentation says that position should be an integer, so it would be an unexpected case where position cannot be ranked that we would be covering. Also, with both rules present, we would have multiple error messages raised for the same defect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep track of the conversation we had on meet, we can go with a numeric validation + sparql validator if this will avoid to flood the report with too many misunderstandable error messages. Feel free to add details here if needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the fixup. Went with the numeric validation we discussed rather than bringing the count rule back. A second SPARQL constraint reports any position the order rule can't rank.

[{"position": "a"}, {"position": "b"}] now fails, so the regression you spotted is closed, and non-numeric-positions-two-omit moved into the failing group.

On the messages concern: the two constraints are independent, the order rule only fires when the cast succeeds, the position rule only when it doesn't. So one defect never produces two messages. A trail with three bad positions gets three position errors, one per entry, and no item errors on top.

Comment thread wordlift_sdk/validation/generator.py Outdated
# Google: the final breadcrumb entry may omit `item`. The SPARQL
# constraint replacing this minCount is emitted only from the
# top-level BreadcrumbList branch in _write_feature; if
# BreadcrumbList ever becomes a child type in _SCOPED_CHILD_RULES,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment documents that nested BreadcrumbList shapes would silently lose item enforcement if BreadcrumbList is ever added as a child type in _SCOPED_CHILD_RULES — but nothing actually checks for that happening. Worth noting: pre-PR, this function did handle the nested case (it called _emit_breadcrumb_item_exemption from here too, symmetric with the _write_feature call site); that call site was removed without a SPARQL-constraint equivalent, since a named sh:SPARQLConstraint doesn't drop into _emit_node's inline-blank-node pattern as naturally. So this is a real gap that's a scope call, not something that was structurally forced.

Rather than relying on someone reading this comment before extending _SCOPED_CHILD_RULES, a tripwire test would catch it automatically:

from wordlift_sdk.validation.generator import _SCOPED_CHILD_RULES


def test_breadcrumb_list_is_not_yet_a_scoped_child_type() -> None:
    """Tripwire, not a behavior test. _emit_breadcrumb_item_order's SPARQL
    constraint is wired up only from _write_feature's top-level BreadcrumbList
    branch, not from _emit_node — so if BreadcrumbList is ever nested as a
    child type here, nested breadcrumbs would silently stop enforcing `item`
    (see the comment on _emit_node's BreadcrumbList skip in generator.py).

    This test passes today because that hasn't happened. If it starts
    failing, don't just update the assertion: _emit_node needs to also emit
    the breadcrumb item-order constraint for the nested case first.
    """
    for parent_type, rules in _SCOPED_CHILD_RULES.items():
        for prop, child_types in rules.items():
            assert "BreadcrumbList" not in child_types, (
                f"{parent_type}.{prop} now nests BreadcrumbList — "
                "_emit_node must emit the breadcrumb item-order SPARQL "
                "constraint for this nested shape, not just skip item's "
                "minCount (see _write_feature's emit_breadcrumb_item_order)."
            )

Passes today, fails the moment the risk becomes real, and the failure message tells whoever trips it exactly what to fix — regardless of whether they read this comment first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as test_breadcrumb_list_is_not_yet_a_scoped_child_type in tests/test_shacl_generator.py. AGENTS.md now points at the test instead of asking people to read the comment.

@mauanga
mauanga requested a review from rpanfili September 17, 2026 14:51

@rpanfili rpanfili left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks

@mauanga
mauanga force-pushed the breadcrumb-last-entry branch from 9906d29 to 5221f2a Compare September 18, 2026 09:42
@mauanga
mauanga merged commit 1e931ba into main Sep 18, 2026
12 checks passed
@mauanga
mauanga deleted the breadcrumb-last-entry branch September 18, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants