Handling Multi-Pack SKU Variants in Compliance Scoring

This walkthrough sits under Threshold Tuning for Compliance Accuracy and solves one precise task the tuning layer cannot fix by moving a cut-point: scoring compliance correctly when the same product ships as a single, a multi-pack, and a club-pack, each carrying its own GTIN, yet all looking near-identical on the shelf. The parent page tunes where a confidence cut-point sits; this page decides which GTINs are interchangeable before that cut-point is ever applied. Treat those three GTINs as three unrelated SKUs and a store that legitimately swapped the single for the 6-pack lights up as a misplacement, while the 12-pack club unit either double-counts its facings or fails to count at all. The fix is a variant-equivalence group keyed to a base product: any in-group GTIN is an acceptable fill for the slot, but the facings and pack size are always scored against the planogram’s specified variant, and the visual-confidence floor is raised where variants are visually indistinguishable. This page builds that resolver and scorer step by step, and each step is independently verifiable.

A base-product variant-equivalence group of single, multi, and club GTINs feeding a scoring gate that separates substitution from violation On the left, a base product node labelled BP-4471 fans out along three equivalence links to three purchasable GTIN variants stacked in the middle: a single with pack size one, a multi-pack with pack size six, and a club-pack with pack size twelve. Each variant carries its own GTIN but shares the same base product. All three variants flow into a scoring gate on the right that applies three rules — accept any in-group GTIN as a valid fill for the slot, score pack-size and facings against the planogram's specified variant rather than the one on shelf, and raise the visual-confidence floor for look-alike variants from about zero point five five to zero point seven eight. The gate emits two distinct outcomes: an in-group variant swap is graded as a substitution and is explicitly not misplaced, while a detection resolving to a different base product is graded as a violation. Base product Variant-equivalence group Scoring gate Outcome base product BP-4471 one catalog key single · pack 1 GTIN …0017 multi-pack · pack 6 GTIN …0062 club-pack · pack 12 GTIN …0121 equivalence accept any in-group GTIN score facings vs the specified variant raise look-alike confidence floor 0.55 → 0.78 substitution in-group swap, not misplaced violation different base product

Prerequisites & Context Jump to heading

Before applying this page, confirm the following are already in place. Variant scoring runs after detection and slot assignment — it consumes resolved GTINs, not pixels.

  • Runtime: Python 3.11+ on the scoring host; no GPU is required for this stage, which is pure dictionary lookup and arithmetic.
  • A GTIN variant/equivalence group in the catalog: every purchasable GTIN must resolve to a base product, with its pack_size (units per pack) and packaging width_cm / height_cm. This is the authoritative catalog resolved by Planogram Sync & SKU Mapping Strategies; if a GTIN is not registered to a group, this stage cannot know it is a valid substitute.
  • Resolved detections: each detection arrives already localized and classified to a candidate GTIN with a visual_confidence score — the output of Bounding Box Extraction & SKU Localization, which is also where near-identical variants are hardest to tell apart.
  • Planogram slot spec: every slot_id with the specific expected_gtin the reset called for and its expected_facings. The reset chose a variant deliberately; the score must honour that choice even when a valid cousin is on shelf.
  • A ground-truth reconciliation target: a small labelled set so the confidence floor you raise here can be checked against a human verdict, the loop the parent Threshold Tuning for Compliance Accuracy formalizes.

A note on terms: a variant-equivalence group is the set of GTINs that satisfy the same shelf intent. A substitution is a valid in-group swap; a violation is a genuinely wrong product. The whole point is to never confuse the two.

Step 1 — Model a Variant-Equivalence Group Keyed to a Base Product Jump to heading

The unit of truth is the base product, not the GTIN. Model an equivalence group as a base-product key mapping to its member variants, each with a distinct pack size, then build a reverse index so any detected GTIN resolves back to its group in constant time. Distinct pack sizes are a validation invariant: two members sharing a pack_size means the catalog has a duplicate, and duplicates are exactly what produce double-counted facings later.

from dataclasses import dataclass


@dataclass(frozen=True)
class VariantSpec:
    """One purchasable GTIN of a base product, with pack size and dimensions."""
    gtin: str
    pack_size: int          # units per pack: 1 single, 6 multi, 12 club
    width_cm: float
    height_cm: float


@dataclass(frozen=True)
class VariantGroup:
    """An equivalence group keyed to a base product. Every member is a valid
    fill for a slot; only one member matches the planogram's specified variant."""
    base_product_id: str
    members: dict[str, VariantSpec]     # gtin -> spec
    look_alike: bool = False            # variants are near-identical on shelf

    def __post_init__(self) -> None:
        if not self.members:
            raise ValueError(f"group {self.base_product_id} has no members")
        sizes = [v.pack_size for v in self.members.values()]
        if len(set(sizes)) != len(sizes):
            raise ValueError("each member must have a distinct pack_size")


class VariantRegistry:
    """Reverse index from any GTIN to its base-product equivalence group."""

    def __init__(self, groups: list[VariantGroup]) -> None:
        self._by_gtin: dict[str, VariantGroup] = {}
        for group in groups:
            for gtin in group.members:
                if gtin in self._by_gtin:
                    raise ValueError(f"GTIN {gtin} registered to two groups")
                self._by_gtin[gtin] = group

    def group_for(self, gtin: str) -> VariantGroup | None:
        return self._by_gtin.get(gtin)

Because the registry is keyed off the base product, adding a new club-pack GTIN next quarter is a one-line catalog change, not a re-tune of the scorer.

Step 2 — Accept Any In-Group GTIN, Score Against the Specified Variant Jump to heading

With the group modelled, the slot spec names one deliberate variant while the shelf may hold any member. The gate accepts the in-group GTIN as a valid fill, but the facings are always compared against the planogram’s expected_facings for the specified variant — never the pack size that happens to be on shelf. This is the crux of correct counting: facings are front-facing packs, so a 6-pack occupies one facing exactly like a single, and you never multiply by pack_size to reach a unit count. Multiplying is precisely the bug that double-counts club-packs.

from typing import Literal

ComplianceState = Literal["compliant", "substitution", "violation", "unresolved"]


@dataclass(frozen=True)
class PlanogramSlot:
    slot_id: str
    expected_gtin: str          # the specific variant the reset called for
    expected_facings: int


@dataclass(frozen=True)
class Detection:
    slot_id: str
    gtin: str
    visual_confidence: float
    observed_facings: int       # front-facing packs, NOT unit count


@dataclass(frozen=True)
class SlotResult:
    slot_id: str
    state: ComplianceState
    matched_gtin: str
    expected_gtin: str
    facing_deficit: int
    note: str

The facing deficit is max(0, expected_facings - observed_facings), computed identically for an exact match and an in-group swap, so a valid substitution with a short count is still penalized for the shortfall — just not for being the “wrong” GTIN.

Step 3 — Raise the Visual-Confidence Floor for Look-Alike Variants Jump to heading

A single and its multi-pack often differ only by a shrink-wrap band the camera barely sees, so the detector’s variant call is far less reliable than its base-product call. When a group is flagged look_alike, demand a higher visual_confidence before trusting which member is on shelf; below the floor, return unresolved and let the slot fall through to multi-frame consensus rather than guessing. The base floor of 0.55 is fine for visually distinct products; look-alike groups warrant 0.78 or higher.

@dataclass(frozen=True)
class ScoringConfig:
    base_confidence_floor: float = 0.55
    lookalike_confidence_floor: float = 0.78

    def floor_for(self, group: VariantGroup) -> float:
        return (self.lookalike_confidence_floor
                if group.look_alike else self.base_confidence_floor)

Raising the floor only for look-alike groups keeps distinct products flowing at the normal cut-point while forcing caution exactly where variants blur together, so you buy precision on the ambiguous cases without sacrificing recall everywhere else.

Step 4 — Report Substitution Distinctly from Violation Jump to heading

Now compose the pieces. A detection that resolves to the same base product is a substitution — reportable, but never added to misplaced_sku_list. A detection resolving to a different base product is a violation and a genuine misplacement. An unregistered GTIN or a below-floor look-alike is unresolved and routed for review, not silently graded.

class VariantComplianceScorer:
    def __init__(self, registry: VariantRegistry, cfg: ScoringConfig) -> None:
        self.registry = registry
        self.cfg = cfg

    def score(self, slot: PlanogramSlot, det: Detection) -> SlotResult:
        expected_group = self.registry.group_for(slot.expected_gtin)
        if expected_group is None:
            raise KeyError(
                f"slot {slot.slot_id} expects unregistered GTIN {slot.expected_gtin}"
            )

        det_group = self.registry.group_for(det.gtin)
        deficit = max(0, slot.expected_facings - det.observed_facings)

        if det_group is None:
            return SlotResult(slot.slot_id, "unresolved", det.gtin,
                              slot.expected_gtin, slot.expected_facings,
                              "detected GTIN missing from any catalog group")

        floor = self.cfg.floor_for(expected_group)
        if expected_group.look_alike and det.visual_confidence < floor:
            return SlotResult(slot.slot_id, "unresolved", det.gtin,
                              slot.expected_gtin, slot.expected_facings,
                              f"confidence {det.visual_confidence} below floor {floor}")

        if det_group.base_product_id != expected_group.base_product_id:
            return SlotResult(slot.slot_id, "violation", det.gtin,
                              slot.expected_gtin, deficit,
                              "different base product occupies the slot")

        if det.gtin == slot.expected_gtin:
            return SlotResult(slot.slot_id, "compliant", det.gtin,
                              slot.expected_gtin, deficit, "exact specified variant")

        return SlotResult(slot.slot_id, "substitution", det.gtin,
                          slot.expected_gtin, deficit,
                          "valid in-group variant swap; not misplaced")


if __name__ == "__main__":
    group = VariantGroup(
        base_product_id="BP-4471",
        members={
            "00001": VariantSpec("00001", 1, 6.0, 18.0),
            "00062": VariantSpec("00062", 6, 22.0, 18.0),
            "00121": VariantSpec("00121", 12, 30.0, 20.0),
        },
        look_alike=True,
    )
    other = VariantGroup("BP-9002", {"90020": VariantSpec("90020", 1, 6.0, 18.0)})
    scorer = VariantComplianceScorer(VariantRegistry([group, other]), ScoringConfig())

    slot = PlanogramSlot("SL-14", expected_gtin="00001", expected_facings=4)
    cases = [
        Detection("SL-14", "00001", 0.91, 4),   # exact -> compliant
        Detection("SL-14", "00062", 0.88, 3),   # in-group multi -> substitution
        Detection("SL-14", "90020", 0.90, 4),   # other product -> violation
        Detection("SL-14", "00062", 0.61, 3),   # look-alike, low conf -> unresolved
    ]
    for det in cases:
        r = scorer.score(slot, det)
        print(f"{det.gtin}: {r.state:12s} deficit={r.facing_deficit} | {r.note}")

Reporting consumes the state field directly: only violation contributes to misplaced_sku_list, while substitution flows to a separate assortment-drift channel so a category manager sees “valid cousin on shelf” rather than a phantom misplacement.

Verification & Testing Jump to heading

Confirm each rule deterministically rather than eyeballing a dashboard:

  1. In-group substitution is not misplaced. Score the specified single against a detected multi-pack of the same base product and assert state == "substitution", never "violation", and that its GTIN would not be added to misplaced_sku_list.
  2. Wrong-variant facings are still penalized. Hand the substitution case observed_facings below expected_facings and assert facing_deficit reflects the shortfall — a valid variant does not exempt a short count.
  3. Facings are not double-counted across pack sizes. Assert observed_facings is the pack count and that no code path multiplies by pack_size; a 12-pack club unit must contribute one facing, not twelve.
  4. The look-alike floor is raised. Score a look-alike group at visual_confidence 0.61 and assert state == "unresolved"; drop the same detection into a non-look-alike group and assert it grades normally at the 0.55 floor.
  5. A different base product is a violation. Assert an out-of-group GTIN grades violation, and that an unregistered GTIN grades unresolved while an unregistered expected GTIN raises KeyError loudly at load time.

A healthy run shows substitution counts tracking known assortment swaps, violation counts dominated by genuinely wrong products, and the unresolved share small enough to clear through consensus without flooding review.

Troubleshooting Jump to heading

Symptom Likely root cause Remediation
A valid multi-pack swap is flagged misplaced The multi-pack GTIN is not registered to the base-product group, so it resolves to a different group Add the GTIN to the group in the catalog and rebuild the VariantRegistry reverse index; assert group_for returns the same base_product_id
Facings double-count on club-pack slots Facing count multiplies by pack_size to reach a unit count Count front-facing packs only; keep observed_facings pack-based and never multiply by pack_size in the scorer
Multi-pack repeatedly labelled as the single (or vice versa) Look-alike variants accepted below a reliable confidence, so the variant call is a guess Set look_alike=True on the group and raise lookalike_confidence_floor toward 0.78+; route below-floor detections to multi-frame consensus
unresolved volume spikes on one category Catalog is missing the equivalence group entirely, so no GTIN resolves Register the base-product group in Planogram Sync & SKU Mapping Strategies before scoring; a missing group should alert, not silently pass
Substitutions inflate the misplacement rate Reporting reads any non-exact GTIN as misplaced Branch reporting on state: only violation enters misplaced_sku_list; send substitution to the assortment-drift channel
Back to top