Require breadcrumb item on every entry but the last - #38
Conversation
d8277f9 to
2c0c241
Compare
| # 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
9906d29 to
5221f2a
Compare
Google requires
itemon 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 missingitemon its first or middle entry validated clean.Fix
Two
sh:sparqlconstraints on theBreadcrumbListshape replace that count rule:itemonly if it holds the highestschema:positionuniquely. 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 omitsitem.positionthe order constraint could not rank, so an unrankable position can never silently exempt the entry beside it.Positions compare as
xsd:double, so2,"2"and2.0match 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.positionis checked only for rankability, not for Google'sIntegertype, so2.5andINFpass. Nogoogle-*.ttlshape 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.