From 15254fb844b63a5f292e035e25eafbc1b78ff74c Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Mon, 7 Sep 2026 18:44:32 -0400 Subject: [PATCH] feat(tool-scraper): record whether a tap cuts its thread or forms it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread former and a cut tap of the same size are the same record: identical DC, TP, SFDM, OAL and LCF, often the same substrate and coating. Nothing downstream could tell them apart, and the two need different hole sizes. `Tool` carries an optional `threadMethod`, `'cutting' | 'forming'`, beside `form` rather than as two more `TOOL_FORMS` values — that vocabulary is Fusion's, and Fusion has no form-tap type. `ToolRecord.threadMethod` holds the same value on a tap and `null` on every other kind; `toolRecord` refuses a tap without one and a non-tap with one. Neither vendor publishes it in a variant table, so it is a per-family fact. Kennametal's three tap families cite its `newTapType` facet, which narrows the same variants endpoint the scrape already calls and answers `3-Forming Tap` for none of them. EMUGE's cite the category split, and the parts state it a second way: every FG01 group carries a `chamfer form` and every FG02 group a `lead taper form`, mutually exclusive across all 551 groups. `emuge_form_taps.csv` scrapes FG02, the vendor's 1,432 cold-forming taps, which went unreached until now — so `forming` is a value the catalog holds rather than one only the type admits. It reads FG01's column labels unchanged, including `length of cutting edge l₂`, which EMUGE keeps on a tool that has no cutting edge. `PRODUCT_LINES` gets no FG02 table: the two categories share their geometry codes and mean different products by them, so a code passes through as the vendor's own. `tests/tap-method.test.ts` walks the family tables and holds every tap family to a cited, in-vocabulary, vendor-stated method, and to both methods being live. `tests/emuge-corpus.test.ts` holds the two EMUGE facts to the chamfer/lead-taper split over a real scrape, which is the only thing that can contradict a fact that no column carries. MariTool's tap leaves and the form/forming rule that classifies them are recorded in `families/maritool.ts` and `docs/MARITOOL_CATALOG.md`, with the mixed-unit problem a tap family there has to solve first. Nothing scrapes them. --- .changeset/tap-thread-method.md | 18 +++ AGENTS.md | 1 + packages/tool-scraper/README.md | 26 ++-- .../docs/EMUGE_FRANKEN_COMMERCE_API.md | 46 ++++++- .../tool-scraper/docs/MARITOOL_CATALOG.md | 50 ++++++- packages/tool-scraper/src/families/emuge.ts | 127 +++++++++++++++--- .../tool-scraper/src/families/kennametal.ts | 40 ++++++ .../tool-scraper/src/families/maritool.ts | 31 +++++ packages/tool-scraper/src/family.ts | 18 ++- packages/tool-scraper/src/records.ts | 63 ++++++++- .../tool-scraper/src/vendors/emuge/records.ts | 12 ++ .../src/vendors/kennametal/records.ts | 1 + .../tool-scraper/tests/emuge-corpus.test.ts | 36 +++++ .../tool-scraper/tests/emuge-families.test.ts | 27 ++++ .../tool-scraper/tests/emuge-records.test.ts | 108 +++++++++++++++ .../tests/kennametal-records.test.ts | 26 +++- packages/tool-scraper/tests/records.test.ts | 43 +++++- packages/tool-scraper/tests/registry.test.ts | 7 +- .../tool-scraper/tests/tap-method.test.ts | 73 ++++++++++ packages/tool-support/src/index.ts | 10 +- packages/tool-support/src/tool.ts | 42 ++++++ packages/tool-support/tests/contracts.test.ts | 16 +++ packages/tool-support/tests/forms.test.ts | 45 ++++++- 23 files changed, 828 insertions(+), 38 deletions(-) create mode 100644 .changeset/tap-thread-method.md create mode 100644 packages/tool-scraper/tests/tap-method.test.ts diff --git a/.changeset/tap-thread-method.md b/.changeset/tap-thread-method.md new file mode 100644 index 0000000..4f5d777 --- /dev/null +++ b/.changeset/tap-thread-method.md @@ -0,0 +1,18 @@ +--- +'@toolpath/tool-support': minor +'@toolpath/tool-scraper': minor +--- + +Record whether a tap cuts its thread or forms it. + +`Tool` takes an optional `threadMethod`, `'cutting' | 'forming'`, beside `form` +rather than as new `TOOL_FORMS` values — the form vocabulary stays Fusion's, and +Fusion has no form-tap type. `ToolRecord.threadMethod` carries the same value on +a tap and `null` on every other kind; `toolRecord` refuses a tap without one and +a non-tap with one. + +Every tap family now states it as a cited fact: Kennametal's three from its +`newTapType` facet, EMUGE's `FG01` from the category it titles `Machine taps`. +And EMUGE's cold-forming taps are scraped for the first time — +`emuge_form_taps.csv`, category `FG02`, 1,432 parts — so `forming` is a value the +catalog actually holds rather than one only the type admits. diff --git a/AGENTS.md b/AGENTS.md index aa45788..070379e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,7 @@ judgment rule starts being violated, give it a check rather than restating it he | A scraper vendor adapter imports no other vendor | `pnpm test` (`vendor-boundary`) | | Only a composition root reaches into `src/vendors/` | `pnpm test` (`vendor-boundary`) | | Every scraper vendor directory has a `scrape.ts` | `pnpm test` (`vendor-boundary`) | +| Every tap family states whether it cuts its thread or forms it | `pnpm test` (`tap-method`) | | `@toolpath/tool-support` imports nothing and declares no dependency | `pnpm test` (`boundary`) | | `@toolpath/app-support`'s root entry imports no React | `pnpm test` (`boundary`) | | `@toolpath/app-support` never imports `@toolpath/ui` | `pnpm test` (`boundary`) | diff --git a/packages/tool-scraper/README.md b/packages/tool-scraper/README.md index 8dbfdc5..f3398ed 100644 --- a/packages/tool-scraper/README.md +++ b/packages/tool-scraper/README.md @@ -17,14 +17,14 @@ pnpm add @toolpath/tool-scraper ## Vendors -| Vendor | Transport | What it publishes | -| ------------------ | ------------------------------------------------------- | ------------------------------------ | -| Kennametal / WIDIA | AEM variant-table GET, parsed with `htmlparser2` | tools and toolholding | -| REGO-FIX | Elasticsearch proxy POST + per-part DIN 4000 XML | toolholding | -| Destiny Tool | Firestore REST, paginated | solid end mills | -| Harvey Tool | inline JS literal on a product page, plus its `` | miniature end mills, keyseat cutters | -| MariTool | osCommerce category listings, then one page per part | toolholding | -| EMUGE-FRANKEN | SAP Commerce JSON API: grouped, variant, batched detail | end mills, twist drills, taps | +| Vendor | Transport | What it publishes | +| ------------------ | ------------------------------------------------------- | ------------------------------------------------- | +| Kennametal / WIDIA | AEM variant-table GET, parsed with `htmlparser2` | tools and toolholding | +| REGO-FIX | Elasticsearch proxy POST + per-part DIN 4000 XML | toolholding | +| Destiny Tool | Firestore REST, paginated | solid end mills | +| Harvey Tool | inline JS literal on a product page, plus its `` | miniature end mills, keyseat cutters | +| MariTool | osCommerce category listings, then one page per part | toolholding | +| EMUGE-FRANKEN | SAP Commerce JSON API: grouped, variant, batched detail | end mills, twist drills, taps cutting and forming | ## Two entry points @@ -77,6 +77,7 @@ toolpath-scrape harvey --catalog # what the four category trees toolpath-scrape maritool maritool_cat40_holders.csv # its leaf categories come from its config toolpath-scrape maritool --catalog # what the five taper trees hold today toolpath-scrape emuge emuge_drills.csv # its category and unit come from its config +toolpath-scrape emuge emuge_form_taps.csv # FG02, the cold-forming taps ``` `toolpath-scrape --help` lists the rest. @@ -99,6 +100,15 @@ here. Every Harvey record is `unspecified`: Harvey's material index is published than in a variant table, and a scrape cannot reach it — see [`docs/HARVEY_PRODUCT_TABLE.md`](docs/HARVEY_PRODUCT_TABLE.md) §1.5.1. +`threadMethod` is `cutting` or `forming` on a tap and `null` on everything else, +because the question does not apply to a drill. It is the one field that +separates a thread former from a cut tap: the two are the same `DC`, `TP`, +`SFDM`, `OAL` and `LCF`, often the same substrate and coating, and a shop drills +a larger hole before one than before the other. Neither vendor publishes it in a +variant table — Kennametal states it in a `newTapType` facet and EMUGE in the +split between its `FG01` and `FG02` categories — so it is a per-family fact, and +each family cites the index it was read off. + Every per-family constant no vendor table states carries its provenance — whether it was vendor-stated, derived or assumed, and by whom on what date. The types enforce it: an assumed fact without a note, a date and initials does not compile. diff --git a/packages/tool-scraper/docs/EMUGE_FRANKEN_COMMERCE_API.md b/packages/tool-scraper/docs/EMUGE_FRANKEN_COMMERCE_API.md index 8e9a383..0b3c17a 100644 --- a/packages/tool-scraper/docs/EMUGE_FRANKEN_COMMERCE_API.md +++ b/packages/tool-scraper/docs/EMUGE_FRANKEN_COMMERCE_API.md @@ -155,6 +155,7 @@ so batching per variant is what a record needs. | `FF01` | End mill cutters | 554 | 7,021 | | `FB01` | Twist drills | 17 | 2,670 | | `FG01` | Machine taps | 414 | 11,566 | +| `FG02` | Cold forming tap | 137 | 1,432 | | `FF02` | Tool holders and accessories | 80 | 625 | `FF02` is not scraped — this package takes EMUGE's cutting tools only. @@ -164,9 +165,49 @@ Milling is split by unit system with the vendor's own facet, and `AMM_EINHS_M` metric (5,189). Drilling and tapping have no such facet and need none — every drill and every tap is published in millimetres. -Roughly 1,700 requests cover the three cutting-tool categories: the group +## 4b. Tapping is two categories, and that is the form/cut split + +The vendor files its taps as `FG01` — which it titles **Machine taps** — and +`FG02`, **Cold forming tap**. A tap in the first cuts its thread away; one in the +second displaces material into it. They are the same three calls against the +same column labels, including `length of cutting edge l₂`, which EMUGE keeps on +a tool that has no cutting edge. + +`FG02` went unscraped until 2026-09-07. Until then `emuge_taps.csv` was this +package's whole tapping corpus and every row in it was a cutting tap, with +nothing on the record saying so. + +**The parts agree with the categories, independently.** Every grouped product +carries a flat `technicalDetails` list, and a tap's names the entry geometry — +a chamfer where it cuts, a lead taper where it forms: + +| Category | Property stated | Groups | Groups stating the other | +| --- | --- | --- | --- | +| `FG01` | `chamfer form` | 414 of 414 | 0 | +| `FG02` | `lead taper form` | 137 of 137 | 0 | + +Mutually exclusive, both directions, at full coverage (JG 2026-09-07). That is +what `families/emuge.ts` cites for each family's `threadMethod`, and +`tests/emuge-corpus.test.ts` re-checks it against a real scrape — a fact rather +than a mapped column cannot otherwise be contradicted by one. + +**Two things that look like the discriminator and are not:** + +- **`Geometry` is not one.** `FG02`'s eight values — `AL`, `GAL`, `H`, `MULTI`, + `SPEED`, `STEEL`, `VA`, `Z` — are all `FG01` values too, and mean different + products: a `Z`-geometry former is InnoForm where a `Z`-geometry cutting tap + is Rekord B-Z. This is why `PRODUCT_LINES` has no `FG02` table and the codes + pass through verbatim. +- **`flute characteristic: without` is not one.** The 189 `FG01` variants + carrying it are six EMUGE *Robust* groups — reinforced cutting taps — and + every one of them still states a `chamfer form`. + +## 4c. Request cost + +Roughly 1,900 requests cover the four cutting-tool categories: the group listings, one variant call per group, and `ceil(variants / 30)` detail calls. -At the package's 400 ms pacing that is about twelve minutes. +At the package's 400 ms pacing that is about thirteen minutes; `FG02` is about +190 of those requests on its own. ## 4a. The product line, and the facet that partitions each category @@ -178,6 +219,7 @@ checked at group level on 2026-09-01 rather than by summing counts: | `FF01` | `AMM_PROG_LINIE` | `product line` | 15 | 554 of 554 | 2 | | `FB01` | `HYB_BAM_SB_GT` | `Geometry` | 4 | 17 of 17 | 0 | | `FG01` | `HYB_BAM_SB_GT` | `Geometry` | 17 | 414 of 414 | 0 | +| `FG02` | `HYB_BAM_SB_GT` | `Geometry` | 8 | 137 of 137 | 0 | All three reach the CSV without a request being added: `Geometry` is on the grouped product's `technicalDetails` and `product line` on the per-part detail diff --git a/packages/tool-scraper/docs/MARITOOL_CATALOG.md b/packages/tool-scraper/docs/MARITOOL_CATALOG.md index cb87d3b..8631025 100644 --- a/packages/tool-scraper/docs/MARITOOL_CATALOG.md +++ b/packages/tool-scraper/docs/MARITOOL_CATALOG.md @@ -155,7 +155,55 @@ calibration/ATC tooling and coolant tubes — 731 further parts under `c23`. holders. ISO30 has three ER parts and none of them publishes a spec table, so the family would be a receipt of nothing. -### 2.3 The roster count +### 2.3 The taps, and how they classify themselves + +Not scraped. Recorded here because MariTool's tree already answers the question +`ToolRecord.threadMethod` exists for, and because the answer wants writing down +while somebody has read the pages. + +Under `c78_148` (`Cutting Tools / Taps`), read 2026-09-07: + +| Leaf | cPath | Method | +| --- | --- | --- | +| Thread Forming Taps | `78_148_274` | `forming` | +| — Plug Form Tap | `78_148_274_275` | `forming` | +| — Bottoming Form Tap | `78_148_274_276` | `forming` | +| Spiral Flute Taps | `78_148_149` | `cutting` | +| — Spiral Flute Plug Taps, Spiral Flute Bottoming | | `cutting` | +| Spiral Point Taps | `78_148_224` | `cutting` | +| Taps for Aluminum | `78_148_271` | `cutting` | +| — Sprial Point Plug Taps *(the vendor's typo)*, Spiral Flute Semi Bottoming | | `cutting` | +| Taper Pipe Taps | `78_148_283` | `cutting` | +| DIN Length HPT Taps | `78_148_284` | `cutting` | +| — Plug Style DIN Taps, Bottoming Style DIN Taps | | `cutting` | + +**Only the forming branch carries the word**, in the leaf name and in every +product title under it — `Plug Style Thread Forming Tap Bright Finish 10-24 H4`. +So `form`/`forming` in the name is the rule, applied when the table is built and +checked into it, never run as a filter at scrape time. §2.2 already gives the +reason: three ER leaves are named `Collet Chucks` rather than `ER Collet Chucks` +and a name match would drop all three silently. + +**What a tap family still needs decided.** A product page publishes enough for a +record — `Shank Size` → `SFDM`, `Total Length` → `OAL`, `Thread Length` → `LCF`, +`Size` + `Pitch Diameter` → a designation `thread.ts` already parses, and no +`DC`, which is the Kennametal case. What it also does is state a **metric thread +on an inch body**: + +``` +Size: M3 Pitch Diameter: 0.5 # millimetres +Shank Size: 0.141 Total Length: 1 15/16 # inches +Thread Limit: D5 Finish: TiN +``` + +`Pitch Diameter` is the vendor's label for what is a pitch on a metric tap and a +thread count on an inch one (`Size: 10`, `Pitch Diameter: 24`). No family in +this package is mixed that way and `ToolRecord.unit` is one value per record, so +this is its own decision — and it is the hazard +`tool-support/src/geometry.ts`'s `TP` docstring already records rather than +resolves. + +### 2.4 The roster count `Displaying 1 to 30 (of 51 products)`, and `Result Pages` paging on `page=N`. 30 products per page; the 41 leaves in scope need **47 listing pages**. diff --git a/packages/tool-scraper/src/families/emuge.ts b/packages/tool-scraper/src/families/emuge.ts index 71f0cae..426a35c 100644 --- a/packages/tool-scraper/src/families/emuge.ts +++ b/packages/tool-scraper/src/families/emuge.ts @@ -1,7 +1,7 @@ /** * EMUGE-FRANKEN's families. * - * Four, one per catalog category crossed with the unit system the category is + * Five, one per catalog category crossed with the unit system the category is * published in. That is coarser than the vendor's own marketing, which splits * end mills fifteen ways by product line — TOP-Cut, Hard-Cut, Alu-Cut — and it * is deliberate: EMUGE states the product line, the cutting material, the @@ -12,8 +12,22 @@ * `PRODUCT_LINE_COLUMNS` reads one per part onto `ToolRecord.productLine`, from * a column every scrape already writes. * - * So the only fact three of these four state is `unit`, and the fourth adds the - * two a drill record cannot be built without. + * So the only fact the two milling families state is `unit`; the drill family + * adds the two a drill record cannot be built without, and each tap family adds + * how its taps make a thread — see `MACHINE_TAP` and `COLD_FORMING_TAP`. + * + * ## The tapping split, which is not a unit split + * + * Tapping is the one category EMUGE publishes as **two**: `FG01`, which it + * titles `Machine taps`, and `FG02`, `Cold forming tap`. They are the same + * three calls against the same column labels and differ only in what the tools + * do — one cuts the thread away, the other displaces material into it — so + * they are two families and not two adapters, and the category is what each + * one's `threadMethod` fact cites. + * + * `FG02` went unscraped until 2026-09-07, which meant `emuge_taps.csv` was the + * whole of this package's tapping corpus and every row in it was a cutting tap + * with nothing recording that it was. * * ## `rows` * @@ -35,7 +49,10 @@ * system to read, because the vendor states one system for all of them. */ +import type { UnitSystem } from '../conventions.js' import type { FamilyDefinition } from '../family.js' +import type { Fact } from '../provenance.js' +import type { ThreadMethod } from '../records.js' /** * The facet EMUGE indexes milling variants by unit system under. @@ -78,8 +95,80 @@ export const SCRAPE_TARGETS = { }, 'emuge_drills.csv': { category: 'FB01' }, 'emuge_taps.csv': { category: 'FG01' }, + 'emuge_form_taps.csv': { category: 'FG02' }, } as const satisfies Record +/** + * Tapping geometry, identical either side of the cutting/forming split. + * + * **EMUGE labels a cold-forming tap's lead `length of cutting edge l₂` too**, + * and it reaches the CSV under that label, because a scraped column keeps the + * vendor's own name — `conventions.ts` opens on the reason. A former has no + * cutting edge and the label is wrong about it; renaming it here would swap one + * vendor's inaccuracy for this package's invention, and the record's `LCF` is + * the canonical name either way. + */ +const TAP_COLUMNS = { + DC: 'nominal diameter d₁', + SFDM: 'Shank diameter d₂', + OAL: 'Overall length l₁', + LCF: 'length of cutting edge l₂', + TP: 'pitch', +} as const + +/** + * Which of EMUGE's two tap categories a family scrapes, and how it is known. + * + * The vendor splits its taps into two categories and scraping either one is + * already the answer — but the *parts* say so as well, independently, which is + * what makes these `vendor-stated` rather than a claim about a URL. Every + * grouped product carries a flat `technicalDetails` list, and: + * + * - all 414 `FG01` groups state `chamfer form` and none states `lead taper + * form`; + * - all 137 `FG02` groups state `lead taper form` and none states `chamfer + * form`. + * + * Mutually exclusive, both directions, at full coverage (JG 2026-09-07). A + * chamfer is ground onto a tap that cuts and a lead taper is rolled onto one + * that forms, so the vendor is naming the same distinction twice. + * `tests/emuge-corpus.test.ts` re-checks that agreement against a real scrape, + * which is what keeps these two constants honest without either family reading + * a column. + * + * **Two things that look like this discriminator and are not**, recorded so + * nobody reaches for them: `Geometry` — the column `vendors/emuge/records.ts` + * reads as the product line — takes `AL`, `GAL`, `H`, `MULTI`, `SPEED`, + * `STEEL`, `VA` and `Z` in *both* categories; and the 189 `FG01` variants whose + * `flute characteristic` is `without` are six EMUGE *Robust* groups, reinforced + * cutting taps that still state a `chamfer form`. + */ +/** + * The unit both tap families are published in. + * + * Shared because the vendor's rule is about tapping and not about either + * category: a `#4-40 UNC` tap states millimetres whichever of the two it sits + * in, which is the same thing the module note says about there being no unit + * facet on tapping at all. + */ +const TAP_MILLIMETERS = { + value: 'millimeters', + source: 'vendor-stated', + cite: 'every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them', +} as const satisfies Fact + +const MACHINE_TAP = { + value: 'cutting', + source: 'vendor-stated', + cite: "the vendor's own category `FG01`, which it titles `Machine taps`; and independently every one of its 414 grouped products states a `chamfer form` and none states a `lead taper form` (JG 2026-09-07)", +} as const satisfies Fact + +const COLD_FORMING_TAP = { + value: 'forming', + source: 'vendor-stated', + cite: "the vendor's own category `FG02`, which it titles `Cold forming tap`; and independently every one of its 137 grouped products states a `lead taper form` and none states a `chamfer form` (JG 2026-09-07)", +} as const satisfies Fact + /** Milling geometry, identical either side of the unit split. */ const MILLING_COLUMNS = { DC: 'cutting diameter Ød₁', @@ -171,20 +260,28 @@ export const FAMILIES = { // `records.DIMENSIONAL_COLUMNS` excluding it means: the vendor publishes // `pitch [mm]` and this family is millimetres, so the column is already in // the record's native unit. - columns: { - DC: 'nominal diameter d₁', - SFDM: 'Shank diameter d₂', - OAL: 'Overall length l₁', - LCF: 'length of cutting edge l₂', - TP: 'pitch', - }, + columns: TAP_COLUMNS, rows: 11566, facts: { - unit: { - value: 'millimeters', - source: 'vendor-stated', - cite: 'every tap dimension is published in millimetres whatever the thread standard — a `#4-40 UNC` tap states `nominal diameter d₁ [mm]` as `2.845 mm`, `pitch [mm]` as `0.635 mm`, and its shank and lengths in `mm` — with `thread symbol`, `nominal size` and `threads per inch` carrying the inch designation beside them', - }, + unit: TAP_MILLIMETERS, + threadMethod: MACHINE_TAP, + }, + }, + // The second half of the vendor's own tapping catalog, and the only forming + // taps this package reaches. It scrapes the same three calls as `FG01` + // against the same column labels — see `TAP_COLUMNS` — so it is a family + // rather than an adapter: what differs is the category, and what the + // category settles. + 'emuge_form_taps.csv': { + id: 'form-taps', + brand: 'emuge', + kind: 'tap', + familyCode: 'FG02', + columns: TAP_COLUMNS, + rows: 1432, + facts: { + unit: TAP_MILLIMETERS, + threadMethod: COLD_FORMING_TAP, }, }, } as const satisfies Record diff --git a/packages/tool-scraper/src/families/kennametal.ts b/packages/tool-scraper/src/families/kennametal.ts index 79d3e3c..91deb70 100644 --- a/packages/tool-scraper/src/families/kennametal.ts +++ b/packages/tool-scraper/src/families/kennametal.ts @@ -22,6 +22,7 @@ import type { UnitSystem } from '../conventions.js' import type { FamilyDefinition, ToolholdingDefinition } from '../family.js' import type { Fact } from '../provenance.js' +import type { ThreadMethod } from '../records.js' /** * Facts several families state identically, named once. @@ -86,6 +87,42 @@ const NO_COOLANT_THROUGH_TAP = { by: 'JG', } as const satisfies Fact +/** + * The tap-type facet, and what it settles. + * + * **The variant table does not carry it.** All three tap families publish + * `D1-TDZ`, `Thread Tolerance Class ANSI`, `Tap Pitch Diameter Limit` and + * `Type of Thread` — the thread's *class*, never how it is produced. What does + * carry it is the vendor's own `newTapType` Solr facet, and it narrows the same + * `.variants..html` endpoint `vendors/kennametal/scrape.ts` already + * calls: `scrape.ACTIVE_ONLY` is one facet on that query and this is a second, + * exactly as `vendors/kennametal/materials.ts` appends one. + * + * Its vocabulary, read off the threading category listing on 2026-09-07, is + * `2-Hand Tap`, `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap`, + * `11-Spiral Point Tap` and `13-Straight Flute Tap`. Exactly one of the six is + * a forming tap; the other five cut. So a family that answers any value but + * `3-Forming Tap` is a cutting family, and each of the three below was probed + * for its own value **and** for `3-Forming Tap`, which returned the vendor's + * no-results notice every time. + * + * That is why these are `vendor-stated` and not `assumed`: the cite is a query + * anybody can re-run, and the *negative* half of it is the part that matters. + * A `cite` naming only the CSV's own filename would be this table reading its + * own name back to itself. + */ +const SPIRAL_POINT_TAP = { + value: 'cutting', + source: 'vendor-stated', + cite: "the vendor's own `newTapType` facet on the variants endpoint: every row of this family answers `:relevance:obsoleteFacet:false:newTapType:11-Spiral Point Tap`, and `3-Forming Tap` — the one forming value of the six — returns the no-results notice (JG 2026-09-07)", +} as const satisfies Fact + +const HAND_TAP = { + value: 'cutting', + source: 'vendor-stated', + cite: 'the same `newTapType` facet: every row of this family answers `2-Hand Tap`, and `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap` and `11-Spiral Point Tap` each return the no-results notice (JG 2026-09-07)', +} as const satisfies Fact + const HSS_ASSUMED = { value: 'hss', source: 'assumed', @@ -332,6 +369,7 @@ export const FAMILIES = { facts: { bmc: HSS_ASSUMED, coolantThrough: NO_COOLANT_THROUGH_TAP, + threadMethod: SPIRAL_POINT_TAP, }, }, 'khsst_hand_metric_plug.csv': { @@ -343,6 +381,7 @@ export const FAMILIES = { facts: { bmc: HSS_ASSUMED, coolantThrough: NO_COOLANT_THROUGH_TAP, + threadMethod: HAND_TAP, }, }, 'spiral_point_metric_plug.csv': { @@ -354,6 +393,7 @@ export const FAMILIES = { facts: { bmc: HSS_ASSUMED, coolantThrough: NO_COOLANT_THROUGH_TAP, + threadMethod: SPIRAL_POINT_TAP, }, }, 'gomill_pro_radiused_4fl_necked_metric.csv': { diff --git a/packages/tool-scraper/src/families/maritool.ts b/packages/tool-scraper/src/families/maritool.ts index 0cdc4af..a2bb78e 100644 --- a/packages/tool-scraper/src/families/maritool.ts +++ b/packages/tool-scraper/src/families/maritool.ts @@ -46,6 +46,37 @@ * * ## What is not scraped * + * **The taps are not scraped, and the evidence for when they are is here.** + * MariTool sells taps under `c78_148`, and its tree already answers the one + * question a tap record needs that no vendor of ours states in a column — + * whether the tap cuts its thread or forms it. Only the forming branch carries + * the word, so the leaf name settles it (JG 2026-09-07): + * + * | Leaf | cPath | Method | + * | ----------------------- | ------------------ | --------- | + * | Thread Forming Taps | `78_148_274` | `forming` | + * | — Plug Form Tap | `78_148_274_275` | `forming` | + * | — Bottoming Form Tap | `78_148_274_276` | `forming` | + * | Spiral Flute Taps | `78_148_149` | `cutting` | + * | Spiral Point Taps | `78_148_224` | `cutting` | + * | Taps for Aluminum | `78_148_271` | `cutting` | + * | Taper Pipe Taps | `78_148_283` | `cutting` | + * | DIN Length HPT Taps | `78_148_284` | `cutting` | + * + * **Checked into a table when it is built, not run as a filter**, for the + * reason the ER leaves above already give: a rule matching a name drops + * silently when the vendor renames a category, and three leaves here are + * already named something other than what they hold. `maritool --catalog` walks + * the tree, which is how the rule gets re-checked rather than trusted. + * + * **What still has to be decided before a tap family lands.** A MariTool tap + * page publishes `Shank Size`, `Total Length`, `Thread Length` and `Size` — + * enough for `SFDM`, `OAL`, `LCF` and a designation `thread.ts` can already + * parse — but a metric tap states `Size: M3` and `Pitch Diameter: 0.5` against + * a body in inches (`Shank Size: 0.141`, `Total Length: 1 15/16`). No family in + * this package is mixed that way and `ToolRecord.unit` is one value, so that is + * a decision on its own merits and not a widening of this table. + * * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill * holders, which is out of scope; ISO30 has three ER parts and none of them * publishes a spec table, so the family would be a receipt of nothing. diff --git a/packages/tool-scraper/src/family.ts b/packages/tool-scraper/src/family.ts index a6a3c80..fca438a 100644 --- a/packages/tool-scraper/src/family.ts +++ b/packages/tool-scraper/src/family.ts @@ -22,13 +22,13 @@ import { ScraperConfigError } from './errors.js' import type { BrandName } from './identity.js' import type { HoldingMapper, ToolholdingKind } from './holding.js' import type { Fact } from './provenance.js' -import type { ColumnMap, ToolKind, ToolRecord } from './records.js' +import type { ColumnMap, ThreadMethod, ToolKind, ToolRecord } from './records.js' import type { MapperOptions, ScrapedRow } from './scrape.js' /** * The per-family constants a fact can carry, and their types. * - * Eleven keys, which is the whole vocabulary the catalog uses. Naming them rather + * Twelve keys, which is the whole vocabulary the catalog uses. Naming them rather * than accepting any string is what lets a mapper read `family.coolantThrough` * as a `boolean` instead of casting an `unknown` out of a bag — and what makes * a fact whose value is the wrong type a compile error where the family is @@ -52,6 +52,20 @@ export interface FamilyFacts { */ profile?: string coolantThrough?: boolean + /** + * How a tap makes its thread — `cutting` or `forming`. + * + * A fact and not a column because neither vendor publishes it per part: it is + * Kennametal's `newTapType` facet, which the variant table does not carry, + * and EMUGE's category, which partitions its taps into two. Every declaration + * in `families/` cites the index it was read off, and each is one value for a + * whole family — checked against the vendor rather than assumed, which is why + * they are `vendor-stated`. + * + * Every `kind: 'tap'` family must state it: `tapRecord` reads it through + * {@link fact}, and `records.toolRecord` refuses a tap record without one. + */ + threadMethod?: ThreadMethod flutes?: number /** Degrees included. */ pointAngle?: number diff --git a/packages/tool-scraper/src/records.ts b/packages/tool-scraper/src/records.ts index aa3de1d..845439a 100644 --- a/packages/tool-scraper/src/records.ts +++ b/packages/tool-scraper/src/records.ts @@ -43,7 +43,11 @@ * separately could supply a tool that claims a holder it does not have. */ -import { GEOMETRY_FIELDS as DICTIONARY, type UnitSystem } from '@toolpath/tool-support' +import { + GEOMETRY_FIELDS as DICTIONARY, + type ThreadMethod, + type UnitSystem, +} from '@toolpath/tool-support' import { dimensionalColumn } from './conventions.js' import { ScraperConfigError } from './errors.js' @@ -75,6 +79,17 @@ export interface GeometryField { iso: string | null } +/** + * How a tap makes its thread. + * + * `@toolpath/tool-support`'s, re-exported under the name this package reads it + * by — the move `conventions.ts` makes for `UnitSystem` and `provenance.ts` for + * `PROVENANCE`. A scrape originates the fact and the domain owns the + * vocabulary, and two declarations of the same two strings is the drift that + * rule exists to prevent. + */ +export type { ThreadMethod } from '@toolpath/tool-support' + /** The kinds of cutting tool this package maps. */ export type ToolKind = 'drill' | 'tap' | 'endmill' @@ -488,6 +503,23 @@ export interface ToolRecord { * a PCD tool. */ readonly nonFerrous: boolean | null + /** + * Taps only, and `null` on every other kind because the question does not + * apply — the shape {@link ToolRecord.nonFerrous} already keeps for drills. + * + * **Not a default, and not derivable from anything else on the record.** A + * former and a cut tap of the same size share their `DC`, `TP`, `SFDM`, + * `OAL` and `LCF`; what separates them is that one displaces material and the + * other removes it, which changes the hole a shop drills first and the feed + * it runs. Guessing `cutting` because most of a catalog is would ship a + * former with a cut tap's drill size. + * + * It is a per-family fact rather than a column, and each of the five + * declarations cites the vendor's own index — see `families/kennametal.ts` + * and `families/emuge.ts`. The invariant below is what stops a sixth tap + * family arriving without one. + */ + readonly threadMethod: ThreadMethod | null } /** @@ -518,10 +550,18 @@ export interface ToolRecord { export function toolRecord( fields: Omit< ToolRecord, - 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' + | 'guid' + | 'materialGroups' + | 'materialGroupsSource' + | 'nonFerrous' + | 'productLine' + | 'threadMethod' > & Partial< - Pick + Pick< + ToolRecord, + 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine' | 'threadMethod' + > >, ): ToolRecord { const groups = fields.materialGroups ?? null @@ -553,10 +593,27 @@ export function toolRecord( ) } + // A tap says how it makes its thread and nothing else does. Both halves are + // load-bearing: a tap record with no method is a family that never declared + // the fact, and a method on a drill is a mapper that copied a line from the + // tap one. Neither can be recovered downstream from what is left on the + // record, because the geometry of a former and a cut tap is the same + // geometry. + const method = fields.threadMethod ?? null + + if ((fields.kind === 'tap') !== (method !== null)) { + throw new ScraperConfigError( + fields.materialNumber, + `a ${fields.kind} record states threadMethod ${JSON.stringify(method)} — ` + + `a tap says how it makes its thread and no other kind does`, + ) + } + checkGeometry(fields.kind, fields.materialNumber, fields.geometry) return Object.freeze({ ...fields, + threadMethod: method, guid: recordGuid(fields.brand, fields.materialNumber), geometry: Object.freeze({ ...fields.geometry }), materialGroups: groups === null ? null : Object.freeze([...groups]), diff --git a/packages/tool-scraper/src/vendors/emuge/records.ts b/packages/tool-scraper/src/vendors/emuge/records.ts index bfa7fb5..85032c9 100644 --- a/packages/tool-scraper/src/vendors/emuge/records.ts +++ b/packages/tool-scraper/src/vendors/emuge/records.ts @@ -189,6 +189,7 @@ export const PRODUCT_LINE_COLUMNS: Readonly> = { FF01: 'product line', FB01: 'Geometry', FG01: 'Geometry', + FG02: 'Geometry', } /** @@ -212,6 +213,16 @@ export const PRODUCT_LINE_COLUMNS: Readonly> = { * vendor's marketing rather than a hole here: `SPEED`, `FK`, `GAL`, `GG` and * `TILEG` are real lines with no `/a/` page on the US storefront, so the * honest answer is the vendor's own code until one appears. + * + * **`FG02` has no entry at all, on purpose.** Cold-forming taps index by the + * same eight geometry codes `FG01` uses — `AL`, `GAL`, `H`, `MULTI`, `SPEED`, + * `STEEL`, `VA`, `Z` — and mean different products by them: a `Z`-geometry + * former is InnoForm, not the `Rekord B-Z Taps` the `FG01` table would name it. + * Borrowing that table would put a cutting tap's product line on a forming tap, + * which is the one error this category split exists to make impossible. So the + * codes pass through verbatim, which is what the paragraph above already says a + * code with no article page does, and naming them from the vendor's own + * cold-forming pages is the follow-up. */ export const PRODUCT_LINES: Readonly>>> = { // `/us/en/multi-drill/a/MultiDRILL`, `/us/en/steeldrill/a/SteelDrill`, @@ -618,6 +629,7 @@ export function tapRecord( ...common(row, family, what, warn), kind: 'tap', unit, + threadMethod: fact(family, 'threadMethod', family.threadMethod), geometry: { DC: required(row, columns, 'DC', unit, what, opts), TP: required(row, columns, 'TP', unit, what, opts), diff --git a/packages/tool-scraper/src/vendors/kennametal/records.ts b/packages/tool-scraper/src/vendors/kennametal/records.ts index 952b3d9..8e191fe 100644 --- a/packages/tool-scraper/src/vendors/kennametal/records.ts +++ b/packages/tool-scraper/src/vendors/kennametal/records.ts @@ -285,6 +285,7 @@ export function tapRecord(row: ScrapedRow, family: BoundFamily, columns: ColumnM coating: row[COATING] ?? '', ...materialGroups(row), coolantThrough: fact(family, 'coolantThrough', family.coolantThrough), + threadMethod: fact(family, 'threadMethod', family.threadMethod), geometry: { DC: threadMajorDiameter(tdz, system), TP: require_(row, columns, 'TP', unit, what), diff --git a/packages/tool-scraper/tests/emuge-corpus.test.ts b/packages/tool-scraper/tests/emuge-corpus.test.ts index 7c525f2..5ac4c6e 100644 --- a/packages/tool-scraper/tests/emuge-corpus.test.ts +++ b/packages/tool-scraper/tests/emuge-corpus.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest' import { checkIdentityColumns, dimensionalColumn } from '../src/conventions.js' +import type { FamilyDefinition } from '../src/family.js' import { FAMILIES } from '../src/families/emuge.js' import { boundFamily, toRecords } from '../src/registry.js' import { unionHeader } from '../src/scrape.js' @@ -49,6 +50,41 @@ describe('the scraped EMUGE-FRANKEN catalog', () => { }) } + const TAPS: readonly (readonly [string, FamilyDefinition])[] = Object.entries( + FAMILIES as Record, + ).filter(([, cfg]) => cfg.kind === 'tap') + + for (const [name, cfg] of TAPS) { + const forming = cfg.facts?.threadMethod?.value === 'forming' + const stated = forming ? 'lead taper form' : 'chamfer form' + const absent = forming ? 'chamfer form' : 'lead taper form' + + it(`${name}: every part states a ${stated} and no ${absent}`, (ctx) => { + // **The sensor for a family constant nothing else can check.** + // `threadMethod` is a fact rather than a mapped column, so a scrape + // cannot contradict it the way a lost column contradicts a declared + // `unit` — the record would simply carry whatever the table said. + // + // But EMUGE states the same distinction a second way, per part: it grinds + // a chamfer onto a tap that cuts and rolls a lead taper onto one that + // forms, and publishes whichever applies in `technicalDetails`. Every one + // of `FG01`'s 414 groups states `chamfer form` and none states `lead + // taper form`; every one of `FG02`'s 137 states the reverse (JG + // 2026-09-07). Both properties reach the CSV unmapped, so this is the + // vendor's own second opinion on the fact, over the whole catalog rather + // than over a fixture. + // + // A failure here is not a broken test. It is the vendor having moved a + // tap between categories, or having renamed the property — and either way + // the family's `threadMethod` is now a claim about parts that no longer + // support it. + for (const row of rows(ctx, name)) { + expect(row[stated], row[MATERIAL_NUMBER_COLUMN]).toBeTruthy() + expect(row[absent] ?? '', row[MATERIAL_NUMBER_COLUMN]).toBe('') + } + }) + } + it('gives every part a number nothing else in the catalog claims', (ctx) => { // A part listed under two categories would be written into two CSVs, and // both receipts would agree with themselves. The two end mill families are diff --git a/packages/tool-scraper/tests/emuge-families.test.ts b/packages/tool-scraper/tests/emuge-families.test.ts index 5cfb2a9..6d8e59e 100644 --- a/packages/tool-scraper/tests/emuge-families.test.ts +++ b/packages/tool-scraper/tests/emuge-families.test.ts @@ -49,6 +49,33 @@ describe('the family table and the target table', () => { } }) + it('scrapes both halves of the vendor’s tapping catalog, as two categories', () => { + // EMUGE splits taps into `FG01` (`Machine taps`) and `FG02` (`Cold forming + // tap`). `FG02` went unscraped until 2026-09-07, which meant every tap in + // this package's corpus was a cutting tap and nothing recorded that it + // was. Two categories, so two families — unlike milling, which is one + // category under two facets. + expect(SCRAPE_TARGETS['emuge_taps.csv'].category).toBe('FG01') + expect(SCRAPE_TARGETS['emuge_form_taps.csv'].category).toBe('FG02') + + const taps = NAMES.filter((name) => FAMILIES[name as keyof typeof FAMILIES].kind === 'tap') + expect(taps.sort()).toEqual(['emuge_form_taps.csv', 'emuge_taps.csv']) + + // The vendor's own result counts for exactly these two queries, read on + // 2026-09-07 — the second number `node/receipts.checkRows` compares a + // scrape against. + expect(FAMILIES['emuge_taps.csv'].rows).toBe(11566) + expect(FAMILIES['emuge_form_taps.csv'].rows).toBe(1432) + }) + + it('reads both tap families through one set of column labels', () => { + // EMUGE labels a former's lead `length of cutting edge l₂` exactly as it + // labels a cut tap's flute length, so the two families share a column map + // and differ only in the category and what the category settles. + expect(FAMILIES['emuge_form_taps.csv'].columns).toEqual(FAMILIES['emuge_taps.csv'].columns) + expect(Object.values(FAMILIES['emuge_taps.csv'].columns)).toContain('length of cutting edge l₂') + }) + it('gives the two end mill families one category and two facets of it', () => { // Same `familyCode` on both, which is right — they are two facets of one // vendor category rather than two categories — so the facet is the only diff --git a/packages/tool-scraper/tests/emuge-records.test.ts b/packages/tool-scraper/tests/emuge-records.test.ts index 8c7bc68..3b27bf4 100644 --- a/packages/tool-scraper/tests/emuge-records.test.ts +++ b/packages/tool-scraper/tests/emuge-records.test.ts @@ -198,6 +198,67 @@ const TAP_DETAIL = { const tapRow = (): ScrapedRow => variantRow(TAP_GROUP, TAP_VARIANT, TAP_DETAIL, 'millimeters') +/* ------------------------------------------------------------------ form tap */ + +/** + * A cold-forming tap out of `FG02`, trimmed from the live responses on + * 2026-09-07. + * + * The same part number in the same thread as {@link TAP_VARIANT} would be too + * neat to be evidence, so this is the real `Nr.4-40 UNC-2BX` former EMUGE sells + * beside it — `BU37Z700.5003`, out of the `US-InnoForm 1-Z-SN-PM-TIN-80` group. + * Note what it shares with the cutting tap above and what it does not: an + * identical dimension table down to the column labels, the same `Geometry: Z`, + * the same `#4` at 40 TPI — and `lead taper form` where the cutting tap states + * `chamfer form`. Nothing but the category tells the two apart, which is the + * whole reason `threadMethod` exists. + */ +const FORM_TAP_GROUP = { + code: 'H102080', + productListInfo: 'EMUGE-Z style cold forming tap, (UNC, UNF threads).', + technicalDetails: [ + { property: 'lead taper form', value: 'Form C (Semi-Bottoming)' }, + { property: 'thread orientation', value: 'internal' }, + { property: 'Geometry', value: 'Z' }, + ] as Property[], +} + +const FORM_TAP_VARIANT = { + code: '000000000010563370', + articleCode: 'BU37Z700.5003', + dimensionFeatureValue: 'Nr.4-40 UNC-2BX', + mainDrawing: { + technicalDetails: [ + { property: 'nominal diameter d₁ [mm]', value: '2.845 mm' }, + { property: 'Shank diameter d₂', value: '3.581 mm' }, + { property: 'Overall length l₁', value: '56 mm' }, + // The vendor's own label on a tool with no cutting edge. It reaches the + // CSV as published — see `families/emuge.ts`'s `TAP_COLUMNS`. + { property: 'length of cutting edge l₂', value: '6 mm' }, + { property: 'usable length l₃', value: '18 mm' }, + { property: 'square ◘', value: '2.79 mm' }, + ] as Property[], + }, +} + +const FORM_TAP_DETAIL = { + code: FORM_TAP_VARIANT.code, + technicalDetails: [ + { property: 'thread symbol', value: 'UNC' }, + { property: 'pitch [mm]', value: '0.635 mm' }, + { property: 'threads per inch', value: '40' }, + { property: 'nominal size', value: '#4' }, + { property: 'coolant supply', value: 'Without' }, + { property: 'lead taper form', value: 'Form C (Semi-Bottoming)' }, + { property: 'Coating', value: 'TIN-80' }, + { property: 'Cutting material', value: 'HSSE-PM' }, + ] as Property[], + applicationMaterials: [{ code: 'P' }, { code: 'K' }, { code: 'N' }], +} + +const formTapRow = (): ScrapedRow => + variantRow(FORM_TAP_GROUP, FORM_TAP_VARIANT, FORM_TAP_DETAIL, 'millimeters') + /* --------------------------------------------------------------------- tests */ describe('an end mill', () => { @@ -391,6 +452,53 @@ describe('a tap', () => { expect(tapRow()['thread symbol']).toBe('UNC') expect(tapRow()['threads per inch']).toBe('40') }) + + it('cuts its thread, which is what category FG01 is', () => { + expect(record?.threadMethod).toBe('cutting') + }) +}) + +describe('a cold-forming tap', () => { + const [record] = toRecords('emuge_form_taps.csv', scrapeOf([formTapRow()])) + + it('forms its thread, which is what category FG02 is', () => { + expect(record?.threadMethod).toBe('forming') + }) + + it('is the same record shape as a cutting tap in every other respect', () => { + // The point of the fixture, and the argument for the field. This is the + // `Nr.4-40 UNC-2BX` former sold beside the `Nr.4-40 UNC-2BX` cutting tap + // above, and its geometry is that tap's geometry to the last digit. A + // consumer told to drill for one of these and handed the other drills the + // wrong hole, and nothing else on either record would have said so. + const [cutting] = toRecords('emuge_taps.csv', scrapeOf([tapRow()])) + + expect(record?.kind).toBe('tap') + expect(record?.unit).toBe('millimeters') + expect(record?.geometry).toEqual(cutting?.geometry) + expect(record?.substrate).toBe(cutting?.substrate) + expect(record?.threadMethod).not.toBe(cutting?.threadMethod) + }) + + it('keeps the vendor’s geometry code, because FG01’s names are not FG02’s', () => { + // `Z` is `Rekord B-Z Taps` in the cutting catalog and InnoForm here, so + // `PRODUCT_LINES` has no FG02 table and the code passes through as the + // vendor's own — the documented answer for a code with no article page. + const [cutting] = toRecords('emuge_taps.csv', scrapeOf([tapRow()])) + + expect(record?.productLine).toBe('Z') + expect(cutting?.productLine).toBe('Rekord B-Z Taps') + }) + + it('states a lead taper where a cutting tap states a chamfer', () => { + // The per-part evidence behind the two family facts. Both properties reach + // the CSV; neither is mapped, and `tests/emuge-corpus.test.ts` is what + // holds a real scrape to the same split. + expect(formTapRow()['lead taper form']).toBe('Form C (Semi-Bottoming)') + expect(formTapRow()['chamfer form']).toBeUndefined() + expect(tapRow()['chamfer form']).toBe('Form B (Plug)') + expect(tapRow()['lead taper form']).toBeUndefined() + }) }) /* -------------------------------------------------------------- product line */ diff --git a/packages/tool-scraper/tests/kennametal-records.test.ts b/packages/tool-scraper/tests/kennametal-records.test.ts index 5b8ed7a..2fe981d 100644 --- a/packages/tool-scraper/tests/kennametal-records.test.ts +++ b/packages/tool-scraper/tests/kennametal-records.test.ts @@ -159,7 +159,11 @@ describe('a drill', () => { }) describe('a tap', () => { - const cfg = family('tap', TAP_LABELS, { bmc: 'hss', coolantThrough: false }) + const cfg = family('tap', TAP_LABELS, { + bmc: 'hss', + coolantThrough: false, + threadMethod: 'cutting', + }) const metric: ScrapedRow = { 'Material Number': '1', @@ -258,6 +262,20 @@ describe('a tap', () => { ) }) + it('reads how the thread is made from a fact, and refuses a family without one', () => { + // Kennametal's variant table publishes the thread's *class* — `Thread + // Tolerance Class ANSI`, `Tap Pitch Diameter Limit`, `Type of Thread` — and + // never how it is produced. That is in the vendor's `newTapType` facet, so + // the family states it and the mapper reads it, exactly as it does + // `coolantThrough`. All three of Kennametal's tap families are cutting. + expect(tapRecord(metric, cfg, cfg.columns).threadMethod).toBe('cutting') + + const silent = family('tap', TAP_LABELS, { bmc: 'hss', coolantThrough: false }) + expect(() => tapRecord(metric, silent, silent.columns)).toThrow( + /must state threadMethod as a fact/, + ) + }) + it('carries no material groups, because no tap CSV is swept', () => { // Kennametal indexes no tap by workpiece material — all 129 carry none. // An unswept CSV has no column at all, which is no evidence rather than an @@ -408,7 +426,11 @@ describe('every mapper', () => { // `vendor` is what a downstream consumer displays and joins on; `widia` is // a key in this package's own table and not a thing the vendor calls // itself. - const cfg = family('tap', TAP_LABELS, { bmc: 'hss', coolantThrough: false }) + const cfg = family('tap', TAP_LABELS, { + bmc: 'hss', + coolantThrough: false, + threadMethod: 'cutting', + }) const row: ScrapedRow = { 'Material Number': '1', 'ISO Catalog Number': 'T100', diff --git a/packages/tool-scraper/tests/records.test.ts b/packages/tool-scraper/tests/records.test.ts index 2c45fee..4445a6a 100644 --- a/packages/tool-scraper/tests/records.test.ts +++ b/packages/tool-scraper/tests/records.test.ts @@ -385,7 +385,13 @@ describe('the record itself', () => { // vendor index that rates the part for nothing. The source is a label and // never absent, so "we do not know what this is for" is something a reader // sees rather than something it has to infer from a null. - const record = toolRecord({ ...base, kind: 'tap', coating: '', geometry: TAP }) + const record = toolRecord({ + ...base, + kind: 'tap', + coating: '', + geometry: TAP, + threadMethod: 'cutting', + }) expect(record.materialGroups).toBeNull() expect(record.materialGroupsSource).toBe(UNSPECIFIED) @@ -394,6 +400,41 @@ describe('the record itself', () => { expect(record.nonFerrous).toBeNull() }) + it('carries a thread method on a tap, and none on anything else', () => { + // The one field that separates a form tap from a cut tap. Every other + // number on the two records is the same number, so a tap that reaches a + // consumer without it is a tap the consumer cannot drill the right hole + // for — and `null` is the answer on a drill or an end mill because the + // question does not apply, not because the answer is unknown. + const tap = toolRecord({ ...base, kind: 'tap', geometry: TAP, threadMethod: 'forming' }) + + expect(tap.threadMethod).toBe('forming') + expect(toolRecord(base).threadMethod).toBeNull() + }) + + it('refuses a tap that states no method, and a non-tap that states one', () => { + // Both halves are load-bearing. A tap with no method is a family that never + // declared the fact — `fact()` catches that first, and this is the second + // gate for a mapper that reached round it. A method on a drill is a line + // copied out of the tap mapper, and it would read downstream as a claim + // about a tool the word does not describe. + expect(() => toolRecord({ ...base, kind: 'tap', geometry: TAP })).toThrow(ScraperConfigError) + expect(() => toolRecord({ ...base, kind: 'tap', geometry: TAP })).toThrow( + /4151623.*a tap record states threadMethod null/, + ) + expect(() => toolRecord({ ...base, threadMethod: 'cutting' })).toThrow( + /endmill record states threadMethod "cutting"/, + ) + expect(() => + toolRecord({ + ...base, + kind: 'drill', + geometry: { DC: 6.0, SFDM: 6.0, OAL: 80, LCF: 20, NOF: 2 }, + threadMethod: 'forming', + }), + ).toThrow(/a drill record states threadMethod "forming"/) + }) + it('keeps an empty index as a real answer, distinct from no index', () => { // Kennametal's 129 taps are swept and rated for nothing. Reading that as // "unconstrained" would put every tap under every material; reading it as diff --git a/packages/tool-scraper/tests/registry.test.ts b/packages/tool-scraper/tests/registry.test.ts index 39b9920..58ed4d0 100644 --- a/packages/tool-scraper/tests/registry.test.ts +++ b/packages/tool-scraper/tests/registry.test.ts @@ -229,11 +229,14 @@ describe('the catalog sources what no table states', () => { // differs: a tap has no `unit` — its rows carry their own `Thread System` // — and no `flutes` constant, because it publishes a `Z` column. It does // need `coolantThrough`: the tap mapper hardcoded `false` until 2026-08-29, - // which is the same claim with nothing standing behind it. + // which is the same claim with nothing standing behind it. And it needs + // `threadMethod`, because neither vendor publishes per part whether a tap + // cuts its thread or forms it — see `tests/tap-method.test.ts`, which holds + // that fact to more than its presence. const required: Record = { drill: ['unit', 'flutes', 'pointAngle', 'coolantThrough', 'nonFerrous', 'bmc'], endmill: ['unit', 'coolantThrough', 'bmc'], - tap: ['bmc', 'coolantThrough'], + tap: ['bmc', 'coolantThrough', 'threadMethod'], } // **Unless the vendor states it per part, in which case a fact would be diff --git a/packages/tool-scraper/tests/tap-method.test.ts b/packages/tool-scraper/tests/tap-method.test.ts new file mode 100644 index 0000000..c34c766 --- /dev/null +++ b/packages/tool-scraper/tests/tap-method.test.ts @@ -0,0 +1,73 @@ +/** + * The one fact a tap family cannot be added without, and what it has to be. + * + * A form tap and a cut tap of the same size are the same numbers: identical + * `DC`, `TP`, `SFDM`, `OAL` and `LCF`, and often the same substrate, coating + * and thread designation. Nothing on a record separates them but + * `threadMethod`, and getting it wrong is not a display fault — a shop drills a + * larger hole before a former than before a cut tap, so a former labelled + * `cutting` is a broken tap rather than a wrong label. + * + * `tests/registry.test.ts` already checks that every tap family declares the + * fact, in the table that says the same thing about `bmc` and `coolantThrough`. + * This holds it to three things that table cannot: that the value is one of the + * two the domain publishes, that it is `vendor-stated` rather than assumed, and + * that its citation names something specific enough to re-check. + * + * **`vendor-stated` is the bar, and it is not a formality.** Both vendors + * publish the distinction outright — Kennametal in a `newTapType` facet on the + * same endpoint the scrape already calls, EMUGE in the category split between + * `FG01` and `FG02` — so there is nothing here to assume, and a family that + * arrives with an `assumed` method is one nobody looked the answer up for. + * Neither cites a CSV filename: `khsst_spiral_point_plug_inch` is this + * package's own name for the file and reading it back would be the table citing + * itself. + * + * Derived from the family tables rather than from a roster of names, so a fifth + * tap family is covered the day it is written. + */ + +import { describe, expect, it } from 'vitest' + +import { THREAD_METHODS } from '@toolpath/tool-support' + +import { FAMILIES } from '../src/families/index.js' + +const taps = Object.entries(FAMILIES).filter(([, cfg]) => cfg.kind === 'tap') + +describe('every tap family says how its taps make a thread', () => { + it('has taps to check at all', () => { + // The guard the scan needs: a filter that matched nothing would pass every + // assertion below by never running one. + expect(taps.length).toBeGreaterThan(0) + }) + + it.each(taps)('%s states a method the domain publishes', (name, cfg) => { + const fact = cfg.facts?.threadMethod + expect(fact, `${name}: no threadMethod fact`).toBeDefined() + expect(THREAD_METHODS, name).toContain(fact?.value) + }) + + it.each(taps)('%s cites the vendor rather than assuming', (name, cfg) => { + const fact = cfg.facts?.threadMethod + expect(fact?.source, name).toBe('vendor-stated') + + // A `vendor-stated` fact carries a `cite` by construction — the union in + // `provenance.ts` will not compile without one — so what is left to check + // is that it says something. `checkFact` refuses the empty string; this + // refuses a citation too short to re-run, and one that names the CSV this + // package invented rather than anything the vendor published. + const cite = fact?.source === 'vendor-stated' ? fact.cite : '' + expect(cite.length, name).toBeGreaterThan(40) + expect(cite, name).not.toContain('.csv') + }) + + it('covers both methods, so neither branch is theoretical', () => { + // Until 2026-09-07 this package scraped EMUGE's `FG01` and not its `FG02`, + // which meant every tap in the corpus was a cutting tap and no test could + // have noticed. A vocabulary with one live value is a field nobody has + // exercised. + const stated = new Set(taps.map(([, cfg]) => cfg.facts?.threadMethod?.value)) + expect([...stated].sort()).toEqual([...THREAD_METHODS].sort()) + }) +}) diff --git a/packages/tool-support/src/index.ts b/packages/tool-support/src/index.ts index a56609d..1ca2306 100644 --- a/packages/tool-support/src/index.ts +++ b/packages/tool-support/src/index.ts @@ -78,7 +78,15 @@ export { type ToolFormEntry, } from './forms.js' -export { hasNeck, shankOf, type Shank, type Tool } from './tool.js' +export { + THREAD_METHODS, + hasNeck, + isThreadMethod, + shankOf, + type Shank, + type ThreadMethod, + type Tool, +} from './tool.js' export { PROFILES_VERSION, diff --git a/packages/tool-support/src/tool.ts b/packages/tool-support/src/tool.ts index 6c6a048..d0b3689 100644 --- a/packages/tool-support/src/tool.ts +++ b/packages/tool-support/src/tool.ts @@ -33,11 +33,53 @@ import type { ProvenanceMap } from './provenance.js' */ const EPSILON = 1e-6 +/** + * How a tap makes its thread: by cutting it away, or by displacing material + * into it. + * + * **A second axis beside {@link Tool.form}, not two more forms.** + * `forms.ts`'s vocabulary is Fusion's own, so that a tool exported there lands + * on the type it already has, and Fusion has no form-tap type — a form tap is a + * `tap right hand` there like any other. Adding `form tap right hand` would buy + * a filter chip and cost that guarantee. It is the same call + * `tool-scraper`'s `families/kennametal.ts` makes for keeping a holder's + * `contact` off its `taper`: two facts about one part are two fields. + * + * The two are genuinely different tools. A former needs a larger hole, evacuates + * no chip, and runs at its own feed; a cut tap does none of that. Nothing here + * branches on it yet — a tap goes in on the hole's axis either way, and + * `tool-drawing` has no publishable shape for either one's lead — but a + * consumer choosing or feeding a tool cannot ask the question at all until the + * fact is carried. + */ +export type ThreadMethod = 'cutting' | 'forming' + +/** + * The methods, in the order a control offers them. + * + * A list beside the union for the reason `PROVENANCE` is one: `tool-scraper` + * validates a family's declared value against it rather than redeclaring the + * two strings, and a filter panel offers it. Cutting leads because it is what + * most of a catalog is. + */ +export const THREAD_METHODS = ['cutting', 'forming'] as const satisfies readonly ThreadMethod[] + +export const isThreadMethod = (value: string): value is ThreadMethod => + THREAD_METHODS.some((method) => method === value) + export interface Tool { /** The CAM-library name for what the tool is: `flat end mill`, `drill`, `slot mill`. */ readonly form: string /** What a machinist calls this one tool — a catalog number, usually. */ readonly label?: string + /** + * How this tap makes its thread, where it is a tap and somebody has said. + * + * Optional, and **absent is nobody having said** rather than a claim of + * cutting — the rule `holding.ts`'s `Clamping` keeps on a holder and `shankOf` keeps + * on a shank. A non-tap carries none, because the question does not apply. + */ + readonly threadMethod?: ThreadMethod readonly geometry: Geometry readonly provenance?: ProvenanceMap } diff --git a/packages/tool-support/tests/contracts.test.ts b/packages/tool-support/tests/contracts.test.ts index 2e3ea30..84d4f3d 100644 --- a/packages/tool-support/tests/contracts.test.ts +++ b/packages/tool-support/tests/contracts.test.ts @@ -35,6 +35,13 @@ const tool: Tool = { provenance: { DC: 'vendor-stated', LBH: 'derived' }, } +const tap: Tool = { + form: 'tap right hand', + label: 'BU37Z700.5003', + threadMethod: 'forming', + geometry: { DC: 2.845, TP: 0.635, LCF: 6, OAL: 56, SFDM: 3.581 }, +} + const holder: Holder = { noseDiameter: 27, noseLength: 12, @@ -137,6 +144,15 @@ describe('a catalog record is a Tool by structure', () => { expect(asTool.geometry.WOC).toBeUndefined() }) + it('carries how a tap makes its thread, where anybody has said', () => { + // Optional, and absent is nobody having said rather than a claim of + // cutting — the rule `Clamping` keeps on a holder. An end mill carries none + // because the question does not apply to it, and the type does not force a + // caller to write that down. + expect(tap.threadMethod).toBe('forming') + expect(tool.threadMethod).toBeUndefined() + }) + it('carries provenance a shop can trace, and nothing implied', () => { // A key with no entry is nobody having said, not a claim of vendor-stated. const stated: ProvenanceMap = tool.provenance ?? {} diff --git a/packages/tool-support/tests/forms.test.ts b/packages/tool-support/tests/forms.test.ts index 08faaa6..c08afff 100644 --- a/packages/tool-support/tests/forms.test.ts +++ b/packages/tool-support/tests/forms.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { MILLING_FORMS, TOOL_FORMS, isToolForm, type ToolForm } from '../src/index.js' +import { + MILLING_FORMS, + THREAD_METHODS, + TOOL_FORMS, + isThreadMethod, + isToolForm, + type ToolForm, +} from '../src/index.js' describe('the tool form vocabulary', () => { it('names each form once', () => { @@ -52,3 +59,39 @@ describe('the tool form vocabulary', () => { } }) }) + +describe('how a tap makes its thread', () => { + it('is a second axis, and not two more forms', () => { + // The vocabulary here is Fusion's, so a tool exported there lands on the + // type it already has — and Fusion has no form-tap type: a thread former is + // a `tap right hand` like any other. Adding `form tap right hand` would buy + // a filter chip and cost that guarantee, so the fact rides beside `form` + // instead. The same call `tool-scraper` makes for a holder's `contact`, + // which does not fold into its `taper`. + const forms: readonly ToolForm[] = TOOL_FORMS.map((form) => form.value) + expect(forms).toContain('tap right hand') + expect(forms).toContain('tap left hand') + for (const form of forms) { + expect(form, form).not.toMatch(/form(ing)? tap/) + } + }) + + it('publishes exactly the two methods, once each', () => { + // Two, and closed: a thread is either cut away or displaced into place, and + // a third value would be a product line rather than a method. + expect([...THREAD_METHODS]).toEqual(['cutting', 'forming']) + expect(new Set(THREAD_METHODS).size).toBe(THREAD_METHODS.length) + }) + + it('accepts what it publishes and nothing else', () => { + // `tool-scraper` validates a family's declared method against this rather + // than redeclaring the two strings. `roll` and `form` are the words a + // vendor's marketing uses for a former, and neither is this vocabulary's. + for (const method of THREAD_METHODS) { + expect(isThreadMethod(method), method).toBe(true) + } + for (const near of ['roll', 'form', 'cut', 'Forming', '']) { + expect(isThreadMethod(near), near).toBe(false) + } + }) +})