[object Object]

← back to Designerwallcoverings

TK-11471: Python twin of the weight guard (lib/weight_guard.py)

e5b279542b64ca1cbb787071db40d60971e16a6a · 2026-09-11 11:42:14 -0700 · Steve Abrams

The .mjs guard covers the JS onboarders, but dwpw-grs-migrate.py — the job that
actually created 80 zero-weight ACTIVE variants on 2026-09-11 — is Python and
cannot import it. A .mjs-only guard is not a guard for this repo.

Constants are byte-identical to weight-guard.mjs (SAMPLE_WEIGHT_LB 0.25,
FALLBACK_LB 2.0, the 17-entry TYPE_DEFAULT_LB table); the accompanying test
asserts that mechanically against the .mjs source so the two cannot drift.

Deliberate divergence: all_zero_weight_variants() counts SAMPLE variants,
matching dw-active-weight-canary. The .mjs zeroWeightBlockers() sample-filter
bug is not ported. Adds response_carries_weight_field() — a gate that reads a
response which never carried weight proves nothing (TK-11431 false-green class).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk

Files touched

Diff

commit e5b279542b64ca1cbb787071db40d60971e16a6a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 11:42:14 2026 -0700

    TK-11471: Python twin of the weight guard (lib/weight_guard.py)
    
    The .mjs guard covers the JS onboarders, but dwpw-grs-migrate.py — the job that
    actually created 80 zero-weight ACTIVE variants on 2026-09-11 — is Python and
    cannot import it. A .mjs-only guard is not a guard for this repo.
    
    Constants are byte-identical to weight-guard.mjs (SAMPLE_WEIGHT_LB 0.25,
    FALLBACK_LB 2.0, the 17-entry TYPE_DEFAULT_LB table); the accompanying test
    asserts that mechanically against the .mjs source so the two cannot drift.
    
    Deliberate divergence: all_zero_weight_variants() counts SAMPLE variants,
    matching dw-active-weight-canary. The .mjs zeroWeightBlockers() sample-filter
    bug is not ported. Adds response_carries_weight_field() — a gate that reads a
    response which never carried weight proves nothing (TK-11431 false-green class).
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk
---
 scripts/lib/weight_guard.py | 299 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 299 insertions(+)

diff --git a/scripts/lib/weight_guard.py b/scripts/lib/weight_guard.py
new file mode 100644
index 0000000..da259a8
--- /dev/null
+++ b/scripts/lib/weight_guard.py
@@ -0,0 +1,299 @@
+#!/usr/bin/env python3
+"""weight_guard.py — TK-11471 PYTHON twin of lib/weight-guard.mjs (same directory).
+
+Steve's rule (TK-11414): NO product may go ACTIVE with a missing/zero product WEIGHT
+(zero weight collapses orders into the lowest weight tier / free-shipping band and
+mis-costs DW freight).
+
+Why a Python twin exists at all: the .mjs guard covers the JS onboarders, but
+dwpw-grs-migrate.py — the job that actually created 80 zero-weight ACTIVE variants
+on 2026-09-11 — is PYTHON and cannot import it. A .mjs-only guard is not a guard
+for this repo. Constants are kept BYTE-IDENTICAL to the .mjs so the two
+implementations cannot drift into disagreeing about what a compliant weight is;
+test_weight_guard.py asserts that mechanically against the .mjs source.
+
+Two call sites:
+  1. create payload:  weight = resolve_weight_lb(variant, product)   (unit POUNDS)
+  2. before activate: r = heal_and_verify_weights(gql, gid, product)
+                      if not r["ok"]: HOLD as draft (never flip ACTIVE at zero weight)
+
+SAMPLES COUNT. dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant —
+its last live run split the offenders 43 sample / 40 sellable. The .mjs's
+zeroWeightBlockers() filters samples OUT and is DEPRECATED there; that bug is
+deliberately NOT ported. all_zero_weight_variants() is the only population view here.
+"""
+import math
+
+SAMPLE_WEIGHT_LB = 0.25
+FALLBACK_LB = 2.0
+
+# product_type -> sellable default weight (POUNDS). Identical to the .mjs table.
+TYPE_DEFAULT_LB = {
+    'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
+    'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
+    'Mural': 4.0,
+    'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
+    'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
+    'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
+    'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
+}
+
+NAN = float('nan')
+
+
+def _norm(t):
+    return ('' if t is None else str(t)).strip().lower()
+
+
+def _num(v):
+    """float(v), or NaN when unparseable — mirrors JS Number() -> NaN. NaN is
+    treated as zero-weight downstream, so a junk value FAILS SAFE."""
+    try:
+        f = float(v)
+    except (TypeError, ValueError):
+        return NAN
+    return f
+
+
+def _d(x):
+    return x if isinstance(x, dict) else {}
+
+
+def _gql_weight(variant):
+    """inventoryItem.measurement.weight from a GraphQL node (camel or snake key)."""
+    iv = _d(variant).get('inventoryItem')
+    if iv is None:
+        iv = _d(variant).get('inventory_item')
+    return _d(_d(iv).get('measurement')).get('weight')
+
+
+def is_sample_variant(variant=None):
+    """Sample variant? (importers create `Sample` @ $4.25 + the real unit)."""
+    v = _d(variant)
+    label = _norm(v.get('title') if v.get('title') is not None else v.get('option1'))
+    sku = _norm(v.get('sku'))
+    if 'sample' in label or 'memo' in label:
+        return True
+    if sku.endswith('-sample') or 'sample' in sku:
+        return True
+    p = _num(v.get('price'))
+    return math.isfinite(p) and abs(p - 4.25) < 0.01
+
+
+def weight_field_present(variant=None):
+    """Did the payload we were handed CARRY a weight field at all?
+
+    TK-11431 amendment 1 — an UNMEASURED input is never a PASS. A response that
+    simply never contained weight must not be read as 'weight is zero' by luck,
+    nor as 'fine'. has_zero_weight() flags both, but a caller that needs to
+    distinguish 'measured 0' from 'never measured' asks here.
+    """
+    v = _d(variant)
+    if _d(_gql_weight(v)).get('value') is not None:
+        return True
+    return v.get('grams') is not None or v.get('weight') is not None
+
+
+def current_weight_lb(variant=None):
+    """Current weight in POUNDS, or 0 if missing. Accepts variant.weight (+weight_unit,
+    REST), variant.grams, or inventoryItem.measurement.weight{value,unit} (GraphQL)."""
+    v = _d(variant)
+    g = _d(_gql_weight(v))
+    if g.get('value') is not None:
+        val = _num(g.get('value'))
+        u = _norm(g.get('unit'))                      # Shopify WeightUnit enum
+        if u == 'kilograms' or u.startswith('kg'):
+            return val * 2.20462
+        if u == 'grams' or u == 'g':
+            return val / 453.59237
+        if u == 'ounces' or u == 'oz':
+            return val / 16
+        return val                                    # POUNDS (unit-less => assume lb)
+    if v.get('grams') is not None:
+        return _num(v.get('grams')) / 453.59237
+    if v.get('weight') is not None:
+        val = _num(v.get('weight'))
+        u = _norm(v.get('weight_unit') or 'lb')
+        if u.startswith('kg'):
+            return val * 2.20462
+        if u == 'g' or u.startswith('gram'):
+            return val / 453.59237
+        if u == 'oz':
+            return val / 16
+        return val                                    # lb
+    return 0.0
+
+
+def has_zero_weight(variant=None):
+    w = current_weight_lb(variant)
+    return (not math.isfinite(w)) or w <= 0
+
+
+def default_weight_lb(variant=None, product=None):
+    """The default weight (lb) to assign a variant that has none."""
+    if is_sample_variant(variant):
+        return SAMPLE_WEIGHT_LB
+    p = _d(product)
+    ptype = p.get('productType') if p.get('productType') is not None else p.get('product_type')
+    return TYPE_DEFAULT_LB.get(ptype, FALLBACK_LB)
+
+
+def resolve_weight_lb(variant=None, product=None):
+    """THE CREATE-SIDE GUARD: keep a real positive weight, else fill the default.
+    Drop-in for the REST variant `weight` field (returns POUNDS; pair with
+    weight_unit='lb')."""
+    w = current_weight_lb(variant)
+    return w if (math.isfinite(w) and w > 0) else default_weight_lb(variant, product)
+
+
+def variants_of(product=None):
+    """Normalize variants out of either a GraphQL connection or a plain REST array."""
+    p = _d(product)
+    edges = _d(p.get('variants')).get('edges')
+    if isinstance(edges, list):
+        return [e.get('node') for e in edges if isinstance(e, dict)]
+    v = p.get('variants')
+    return v if isinstance(v, list) else []
+
+
+def all_zero_weight_variants(product=None):
+    """THE ACTIVATE-SIDE GUARD: EVERY zero-weight variant, SAMPLE INCLUDED.
+    Matches dw-active-weight-canary exactly. Non-empty => DO NOT flip ACTIVE."""
+    return [v for v in variants_of(product) if has_zero_weight(v)]
+
+
+def unmeasured_variants(product=None):
+    """Variants whose payload carried NO weight field at all — the response never
+    measured them. Returned separately so a caller can report NOT-MEASURED rather
+    than claiming a clean zero reading."""
+    return [v for v in variants_of(product) if not weight_field_present(v)]
+
+
+def variant_label(variant=None):
+    v = _d(variant)
+    return v.get('sku') or v.get('title') or v.get('id') or '?'
+
+
+# ---------------------------------------------------------------- activate gate
+# The re-query an activate site MUST run so the guard MEASURES something. A product
+# query that omits inventoryItem{measurement{weight}} makes every variant look
+# zero-weight to current_weight_lb, and one that omits productType silently defaults
+# every heal to FALLBACK_LB. Both fields are required.
+WEIGHT_REQUERY = (
+    "query($id:ID!){ product(id:$id){ productType variants(first:100){edges{node{ "
+    "id sku title price inventoryItem{ id measurement{ weight{ value unit } } } }}} } }"
+)
+
+M_WEIGHT_SET = (
+    "mutation($id:ID!,$w:Float!){ inventoryItemUpdate(id:$id, "
+    "input:{measurement:{weight:{value:$w, unit:POUNDS}}}){ userErrors{message} } }"
+)
+
+
+def response_carries_weight_field(product=None):
+    """Did the re-query response ACTUALLY contain the weight field we asked for?
+
+    A gate that reads a response which never carried weight silently passes
+    everything (or, here, fails everything) for the wrong reason — the false-green
+    class CLAUDE.md warns about. Every variant node must carry an `inventoryItem`
+    key holding a `measurement` key. `measurement.weight = null` is a legitimate
+    'no weight set' reading and is NOT a shape failure.
+    """
+    vs = variants_of(product)
+    if not vs:
+        return False
+    for v in vs:
+        d = _d(v)
+        iv = d.get('inventoryItem', d.get('inventory_item'))
+        if not isinstance(iv, dict) or 'measurement' not in iv:
+            return False
+    return True
+
+
+def heal_and_verify_weights(gql, product_gid, product=None, requery=None, mutation=None,
+                            rest_heal=None):
+    """SELF-HEAL then VERIFY — the pattern Steve approved in
+    sanderson-onboard/scripts/create_sdg.mjs (8d09eed). Stranding product is worse
+    than assigning the already-approved default, but a heal that silently fails must
+    NEVER activate.
+
+      1. every zero-weight variant (SAMPLE INCLUDED) is written default_weight_lb() in POUNDS
+      2. the product is RE-READ and re-checked — the mutation's own 200 is not evidence
+      3. ok is False  => caller must HOLD the product as draft and name `weight>0`
+
+    Idempotent and a NO-OP (zero network calls) when every weight is already positive.
+    Fails CLOSED: an unreadable re-query, a response that never carried the weight
+    field, a missing inventoryItem id, or a userError all yield ok=False.
+
+    gql        -- the call site's own gql(query, variables) -> parsed JSON
+    rest_heal  -- optional fallback heal fn (variant_dict, lb) -> True/False, used when
+                  the GraphQL inventoryItemUpdate path is unavailable (e.g. a token
+                  without write_inventory). Verification stays GraphQL-side either way.
+    """
+    requery = requery or WEIGHT_REQUERY
+    mutation = mutation or M_WEIGHT_SET
+    errs, healed, still_zero = [], [], []
+    heal_failures = 0
+    p = _d(product)
+    product_type = p.get('productType') if p.get('productType') is not None else p.get('product_type')
+
+    zero = all_zero_weight_variants(p)
+    if not zero:
+        return {"ok": True, "healed": healed, "stillZero": [], "errs": errs}   # no-op
+
+    for v in zero:
+        label = variant_label(v)
+        lb = default_weight_lb(v, {"productType": product_type})
+        iid = _d(_d(v).get('inventoryItem') or _d(v).get('inventory_item')).get('id')
+        wrote = False
+        if iid:
+            try:
+                r = gql(mutation, {"id": iid, "w": lb})
+                ue = (((r or {}).get('data') or {}).get('inventoryItemUpdate') or {}).get('userErrors') or []
+                top = (r or {}).get('errors') or []
+                for e in ue:
+                    errs.append("weight:%s:%s" % (label, e.get('message')))
+                for e in top:
+                    errs.append("weight:%s:%s" % (label, str(e.get('message'))[:80]))
+                wrote = not ue and not top
+            except Exception as e:                       # noqa: BLE001 - fail closed
+                errs.append("weight:%s:%s" % (label, str(e)[:80]))
+        else:
+            errs.append("weight:no-inventory-item:%s" % label)
+
+        if not wrote and rest_heal is not None:
+            try:
+                wrote = bool(rest_heal(v, lb))
+            except Exception as e:                       # noqa: BLE001 - fail closed
+                errs.append("weight:rest:%s:%s" % (label, str(e)[:80]))
+
+        if wrote:
+            healed.append({"sku": label, "lb": lb})
+        else:
+            heal_failures += 1
+
+    # RE-VERIFY against the live record. Never trust the write.
+    fresh = None
+    try:
+        resp = gql(requery, {"id": product_gid})
+        if (resp or {}).get('errors'):
+            errs.append("weight:reverify:%s" % str(resp['errors'])[:80])
+        fresh = ((resp or {}).get('data') or {}).get('product')
+    except Exception as e:                               # noqa: BLE001 - fail closed
+        errs.append("weight:reverify:%s" % str(e)[:80])
+    if not fresh:
+        errs.append("weight:reverify-failed")
+        return {"ok": False, "healed": healed, "stillZero": [], "errs": errs}
+    if not response_carries_weight_field(fresh):
+        # NOT MEASURED. The re-query came back without the field it asked for, so a
+        # clean-looking read proves nothing. Never a PASS.
+        errs.append("weight:reverify-response-carried-no-weight-field")
+        return {"ok": False, "healed": healed, "stillZero": [], "errs": errs}
+
+    still_zero = [variant_label(v) for v in all_zero_weight_variants(fresh)]
+    # FAIL CLOSED on an UNHEALED variant even when the re-verify comes back clean: a
+    # variant we could not write is UNMEASURED with respect to our own action, and a
+    # clean re-verify that happens to disagree is not licence to activate. Holding is
+    # reversible and the next run is a no-op, so the conservative branch costs nothing.
+    return {"ok": (not still_zero) and heal_failures == 0,
+            "healed": healed, "stillZero": still_zero, "errs": errs}

← 231a90e TK-11471: every create payload carries a guard-sourced weigh  ·  back to Designerwallcoverings  ·  TK-11471: weight gate on the DWPW->GRS migration (the actual 4d31a1a →