Skip to content

feat(pricing): one engine for wholesale price books, vendors declare themselves - #148

Open
goetchstone wants to merge 4 commits into
mainfrom
feat/wholesale-vendor-registry
Open

feat(pricing): one engine for wholesale price books, vendors declare themselves#148
goetchstone wants to merge 4 commits into
mainfrom
feat/wholesale-vendor-registry

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

Wholesale price books only, as scoped. Order confirmations and apparel parsers stay out.

The insight this is built on

A manufacturer prints one layout and puts several brands on it. Hooker Furnishings prints Hooker, Sam Moore and Bradington-Young on the same column-transposed grid. So the reusable unit is the layout, not the vendor — and naming the modules samMooreExtractor.ts / hookerExtractor.ts had produced three copies of one thing, differing by about four lines each.

Adding a vendor is now a profile module plus a registry line. It does not touch the import route.

Declaration replaces inference — the actual bug

partitionGrades() guessed a grade's material from the shape of its code: a bare letter meant leather, digits meant fabric.

Sam Moore and Hooker both ladder fabric as B..J. Under that guess their entire fabric range files as leather at the wrong tier — and it still imports, the numbers still look plausible, and nothing downstream holds the right answer to compare against. It survives until someone quotes a customer from it.

In the sibling repo the guess had already collected two patches, both living in the import route:

FABRIC_LETTER_GRADE_VENDORS = new Set(["sam moore", "hooker"])
COMBINED_LEATHER_VENDORS    = new Set(["hooker"])

holt hasn't acquired that debt yet, so this lands the seam before it arrives rather than porting it and removing it after. Every rung declares its own kind; partitionGradesFor() uses the vendor's declaration verbatim; the shape rules remain only as a labelled fallback for vendors without a profile.

Profiles are code, not config

Compiled, typed, reviewed. A config format expressive enough to describe a PDF layout is a programming language with a worse type checker — the road rule 62 exists to close.

The split that matters:

Lives in Same for every dealer?
Grade ladder, label spellings, SKU quirks the vendor's profile module yes
Which vendors you carry, markup, retail rules config no

What varies, and what didn't

Three vendors, ~150 lines of profile between them, replacing three ~200-line extractors. The engine keeps what genuinely repeats: the column walk, money and dimension parsing, page splitting, and rendering through columnAwarePageRenderer — raw pdf-parse output is unparseable here, $500$540$470 arrives as one token.

Lookup fails closed on an unknown vendor rather than falling back to a reader that is probably close. It also folds sam-moore and Sam Moore to one key: the form posts the id, the database holds the name, and a lookup matching only one would silently report "no profile" and drop back to guessing. The bug would have been a hyphen.

Verification

Every price in the fixtures is invented — the layout is what the parser keys on, and this repo is public. Both directions checked:

  • filing Sam Moore's letters as leather → fails the declaration case
  • matching LEATHER - NOVELTY before LEATHER - NOVELTY PREMIUM → prices the most expensive rung in the book one tier low, fails another

3,489 unit tests pass; tsc, prettier, lint and markdownlint clean.
docs/domains/vendors-wholesale.md

🤖 Generated with Claude Code

Applies the two-skill pattern from Warp's self-improving agents to this repo's
agent layer, and restructures CLAUDE.md around principles.

PRINCIPLES AS A SPINE, NOT A REPLACEMENT.

Three independent restructures were proposed and then attacked for lost
enforcement. All three lost it, in the same way: generalising a rule into prose
reads better and enforces less. Rule 33's greppable literal and its "No
exceptions"; rule 47's polarity, which is OPPOSITE to 33 (cancelled lines out,
returned orders in) so a polarity-neutral merger permits both errors; rule 31's
stated default; rule 39's "both numerator and denominator"; rule 42's "every
mutation path". In a repo where hooks cite these numbers and 203 files quote
them, that is a regression wearing a cleanup's clothes.

So the rules keep their exact text and their numbers, and ten principles sit
above them. Each principle names a failure mode generally enough to recognise a
NEW instance, and carries a "how it hides" line -- the recurring theme here is
damage that passes every check. Verified mechanically: every rule number is
present exactly once, and every surviving rule's text is byte-identical to
before, except five that gained clarifying additions and none that lost a
clause.

Rule 1 (KISS) retired. Zero citations, no incident, no enforcement home, and
RULE-PROVENANCE.md already said to delete it -- it was the one rule that
contradicted the doctrine it sat under. Number retired, not reused.

THE LOOP.

The gap that produced 65 numbers with gaps and five conflicting citations was
never a missing rule. It was that "does this deserve a rule?" got decided
in-session by the agent that had just been burned, and every session got to
append. That is accretion, not learning.

- docs/RULE-FEEDBACK.md is now the sink. Costs one line, commits to nothing,
  so the signal keeps flowing. Seeded with three entries from this session.
- .claude/skills/improve-rules/SKILL.md is the observer. Runs later, over the
  accumulated pile, and proposes ONE focused edit as a PR. "No change
  warranted" is an explicit success case. Retirement runs the same bar in
  reverse, because a loop that only adds repeats the original failure.
- post-failure and end-of-session no longer edit CLAUDE.md. They append.

ONE DELIBERATE DEVIATION FROM WARP.

Their loop feeds on human PR review comments. Measured here: 20 merged PRs, 0
reviews, 1 comment -- FRAMEWORK.md describes a solo project with no second pair
of eyes, so that well is dry. The substitute is the commit that had to clean up
after the last one: a fix/revert commit is literally what the agent proposed
versus what reality required, with a diff attached, and roughly one commit in
four is one. rules-improver-check.sh counts them and nudges past a threshold --
event-driven, so the pass fires when there is something to read rather than on
a calendar. It never blocks.

3,479 unit tests pass; markdownlint clean.
…themselves

A manufacturer prints one book layout and puts several brands on it. Hooker
Furnishings prints Hooker, Sam Moore and Bradington-Young on the same
column-transposed grid, so the reusable unit is the LAYOUT and a vendor is a set
of parameters against it. Adding a vendor is now a profile module plus a
registry line, not another branch in the import route.

DECLARATION REPLACES INFERENCE, which is the point of the change.

partitionGrades() guessed a grade's material from the SHAPE of its code: a bare
letter meant leather, digits meant fabric. Both Sam Moore and Hooker ladder
FABRIC as B..J, so that guess files their entire fabric range as leather at the
wrong tier -- and it still imports, and the numbers still look plausible, and
nothing downstream holds the right answer to compare against. In the sibling
repo the guess had already collected two patches living in the import route:
FABRIC_LETTER_GRADE_VENDORS and COMBINED_LEATHER_VENDORS. This lands the seam
before that debt arrives rather than porting it and removing it after.

Every rung now declares its own kind, partitionGradesFor() uses the vendor's
declaration verbatim, and the shape rules stay only as a labelled fallback for
vendors that have no profile yet.

Profiles are CODE, not config -- compiled, typed, reviewed. A config format
expressive enough to describe a PDF layout is a programming language with a
worse type checker, which is the road rule 62 exists to close. A deployment
configures which vendors it carries and what markup it applies; a vendor fixes
its grade ladder and label spellings, and those are identical for every dealer
opening the same book.

Three vendors, ~150 lines of profile between them, replacing three
near-identical 200-line extractors. The engine keeps what actually repeats: the
column walk, the money and dimension parsers, page splitting, and rendering
through columnAwarePageRenderer (raw pdf-parse output is unparseable here --
"$500$540$470" arrives as one token).

Lookup fails closed on an unknown vendor rather than falling back to a reader
that is probably close. It also folds "sam-moore" and "Sam Moore" to one key:
the upload form posts the id, the database holds the name, and a lookup matching
only one would silently report "no profile" and drop back to guessing. The bug
would have been a hyphen.

Every price in the fixtures is invented; the layout is what the parser keys on.
Both-directions verified: filing Sam Moore's letters as leather fails one case,
and matching "LEATHER - NOVELTY" before "LEATHER - NOVELTY PREMIUM" prices the
most expensive rung in the book one tier low and fails another.

3,489 unit tests pass; tsc, prettier, lint and markdownlint clean.
Two additions to the wholesale seam, both measured against the real July-2026
Sam Moore book (175 styles, matching the sibling repo's verified anchor; Nova
1034 B $804 / E $909 = COM / J $1,084 / Prem 2 $1,224).

OPTIONS ARE PER STYLE, AND THE BOOK ALREADY SAYS WHICH FRAMES TAKE WHICH.

A book prints ~20 priced option rows beside each frame and they genuinely
differ -- an ottoman's contrast welt is not a sofa's, and some frames cannot
take one at all. Seeding one flat price per vendor asserts every frame carries
every option at one price, which is wrong on most rows and offers a designer
things the vendor will not build.

Applicability needs no curating. It is in the data:

  row absent, or cell "--"   -> not applicable      (the book prints
                                                     "NOTE: -- means Not
                                                     Available" on every page)
  cell "N/C" or "Standard"   -> applicable, free, standard
  cell a number              -> applicable, that surcharge

Measured on the real book: 162 of 175 styles carry options, Contrast Welt on
125, Inside Back Cushion on 80, Top Arm or Panel on 4. Those are the book's
numbers, not a guess.

Three parsing defects found by running it rather than reading it:

- The renderer glues a section heading onto the next row's label, so
  "CONTRAST TOP ARM or PANEL" arrives as
  "STANDARD TRIM & AVAILABLE OPTIONSCONTRAST TOP ARM or PANEL". An anchored
  pattern missed 66 of 86 pages, and the miss read as the option being rare.
  Profiles now declare their glued headings and the engine strips them.
- The book writes "Standard" where other rows write "N/C". Reading only "N/C"
  dropped Welt Only from all 97 styles that carry it.
- One option is printed two ways across the book -- 58 pages say
  "CONTRAST INSIDE BACK CUSHION", 28 say "CONTRAST INSIDE BACK". Matching the
  longer spelling alone lost a $30 upcharge on 80 frames.

Each looked like an option being rare rather than a parser missing it, which is
why none would surface without extracting and counting.

WHOLESALE ONLY.

An extractor reads the vendor's COST. Books also print MSRP, MAP and suggested
retail; none is imported as truth, because retail is a business decision and not
ours to make for someone else's shop -- two dealers carrying the same book price
differently and both are right. A deployment configures its own markup,
discount, and whether it honours MAP; many now just set a flat markup and never
use anchor pricing at all. `msrpRow` captures a printed MSRP as REFERENCE only,
for seeding demo data and sanity-checking a markup, never as the retail.

3,494 unit tests pass; tsc and prettier clean.
…or default

Closes the chain: book row -> engine -> StyleOptionOverride. The extractor read
the option rows in the previous commit; the import still took its options from
VENDOR_SURCHARGE_MAP, a vendor-level flat default that asserts one price for
every frame.

Where the book states a per-frame price the book now wins, and the legacy map
stays as the fallback for vendors whose extractor does not yet read option rows.

Dogfooded end to end against the real July-2026 Sam Moore book, into a scratch
database, then queried as the configurator would. 40 styles produced 275
overrides, averaging 8.1 options per frame and ranging 1 to 10 -- and the two
frames below are why a flat vendor default is not good enough:

  1034 "Nova"    Contrast Welt $20  Bias Welt $40  Seat Cushion $30
                 Out Arm & Back $60  Out Arm & Back (Premium) $300
                 Inside Back Cushion $30  Welt Only included  Carton $25
  1061 "Urban"   Contrast Welt $15  Bias Welt $30  Seat Cushion $20
                 Welt Only included  Carton $20
                 -- no Out Arm & Back, no Inside Back Cushion at all

Same vendor, same book, four different prices and three options one frame
cannot take. A vendor-level seed gets 1061 wrong on every row, and offers a
designer two upcharges the vendor will not build.

The $300 premium-fabric row is one the sibling repo's verify-sam-moore skill has
carried as a known gap ("not seeded"); it now imports.

3,494 unit tests pass; tsc and prettier clean.
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.

1 participant