@instruments/materialgraph 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/browse-types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ByColourSwatch } from "./by-colour-types.js";
1
2
  /**
2
3
  * Wire types for the browse lane — GET `/api/v1/search/browse`.
3
4
  *
@@ -12,9 +13,9 @@
12
13
  * Nothing about the client's surface changed: `index.ts` re-exports these
13
14
  * exactly as before.
14
15
  */
15
- import type { ByColourSwatch } from "./by-colour-types.js";
16
+ import type { CorpusScope, CorpusScopeReceipt } from "./corpus-scope-types.js";
16
17
  import type { Cursor, ProductId, VariantId } from "./identifiers.js";
17
- import type { CountedTotal, OpenEnum } from "./types.js";
18
+ import type { CountedTotal, OpenEnum, SearchFacets } from "./types.js";
18
19
  /** One item from the pure colour-scalar browse. */
19
20
  export interface BrowseResultItem {
20
21
  /** Opaque — round-trip verbatim. */
@@ -43,9 +44,20 @@ export interface BrowseResultItem {
43
44
  * from product imagery, not from the manufacturer.
44
45
  */
45
46
  palette?: ByColourSwatch[];
47
+ /**
48
+ * Resolved values for the attribute ids sent in `returnAttributes`, keyed by
49
+ * attribute id. ABSENT entirely when `returnAttributes` was not sent; a key
50
+ * whose value is `null` means this material carries no accepted value for that
51
+ * attribute. Three states, all different.
52
+ */
53
+ attributes?: Record<string, string | string[] | null>;
46
54
  }
47
- /** The browse orders. `name` (A→Z) is the default; `recent` is newest first. */
48
- export type BrowseSort = OpenEnum<"name" | "recent" | "light" | "dark" | "vivid" | "muted" | "colourful">;
55
+ /**
56
+ * The browse orders. `name` (A→Z) is the default, `name_desc` the same index
57
+ * read Z→A, `recent` newest first; the rest are the colour ramps. `/search`
58
+ * takes all eight too — one word, one order, on both lanes.
59
+ */
60
+ export type BrowseSort = OpenEnum<"name" | "name_desc" | "recent" | "light" | "dark" | "vivid" | "muted" | "colourful">;
49
61
  /**
50
62
  * The browse feed's total — how many materials the browse DESCRIBES, which is
51
63
  * never `results.length`.
@@ -78,21 +90,43 @@ export interface BrowseResponse {
78
90
  * to label its own listing.
79
91
  */
80
92
  sort: BrowseSort;
93
+ /**
94
+ * The attribute ladder this page was ordered by — present ONLY when `orderBy`
95
+ * was sent, in which case it SUPERSEDED `sort` (still echoed above, and still
96
+ * describing nothing this page was ordered by).
97
+ */
98
+ orderBy?: {
99
+ attributeId: string;
100
+ direction: OpenEnum<"asc" | "desc">;
101
+ };
102
+ /**
103
+ * Reader prose about how the request was interpreted — today, only that
104
+ * `orderBy` superseded a STATED `sort`. Absent means nothing needed saying.
105
+ */
106
+ note?: string;
81
107
  /**
82
108
  * The total, present ONLY when `count: true` was passed. Absent means it was
83
109
  * not asked for — never that the set is empty.
84
110
  */
85
111
  counts?: BrowseCounts;
112
+ /**
113
+ * Per-facet value counts, present ONLY when `facetCounts` was asked for.
114
+ *
115
+ * Counted over this browse's OWN match set — the same predicate the page and
116
+ * `counts` ran under — so every value is `basis:"exact"` and `poolSize` is
117
+ * ABSENT. That differs from {@link SearchResponse}'s facets, which are
118
+ * normally counted over a ranked candidate pool and are therefore floors:
119
+ * browse retrieves no pool, so there is nothing for a figure to be a floor
120
+ * of. A facet the server could not count in time appears in `absent` with
121
+ * `reason:"timeout"` and the results are unaffected.
122
+ */
123
+ facets?: SearchFacets;
86
124
  /**
87
125
  * The corpus this page was drawn from — present ONLY when the request
88
126
  * narrowed it. ABSENT means the whole graph: every material MaterialGraph
89
127
  * knows, orderable or not.
90
128
  */
91
- corpusScope?: {
92
- scope: OpenEnum<"all" | "materialBankOrderable">;
93
- /** What the scope admits, in one sentence, for a human reader. */
94
- description: string;
95
- };
129
+ corpusScope?: CorpusScopeReceipt;
96
130
  }
97
131
  /** Params for the pure colour `search/browse` surface. */
98
132
  export interface BrowseParams {
@@ -136,6 +170,30 @@ export interface BrowseParams {
136
170
  * brand's fan deck. An unknown brand is a 400 naming it, never an empty page.
137
171
  */
138
172
  brands?: string;
173
+ /**
174
+ * Typed attribute filters, in the SAME grammar `search` takes — a term, a
175
+ * term SET (OR within the entry), a presence check or a numeric range, each
176
+ * optionally carrying a `trust` bar. Entries AND together.
177
+ *
178
+ * Each entry is sent as ITS OWN `filters=` parameter, JSON-encoded, which is
179
+ * what the route reads. Pass the objects; the client does the encoding.
180
+ *
181
+ * Applied INSIDE the keyset scan and counted under the same predicate, so a
182
+ * filtered page still fills to `limit`, `cursor` still means another MATCHING
183
+ * row exists, and `counts.variants` describes the FILTERED set. An attribute
184
+ * id outside the registry is a 400 carrying ranked candidates, never an empty
185
+ * page.
186
+ */
187
+ filters?: readonly Record<string, unknown>[];
188
+ /**
189
+ * Facet ids to count over this browse's match set — `brand`, `category`, or a
190
+ * spec attribute id. Sent as one comma-separated `facetCounts=` parameter.
191
+ *
192
+ * The response's `facets` block comes back `basis:"exact"` with no
193
+ * `poolSize`, because browse counts the whole predicate rather than a ranked
194
+ * pool. Omit it for no `facets` block and no counting cost.
195
+ */
196
+ facetCounts?: readonly string[];
139
197
  /**
140
198
  * Which corpus to walk. ABSENT means the whole graph — every material
141
199
  * MaterialGraph knows, orderable or not. `materialBankOrderable` restricts to
@@ -143,5 +201,25 @@ export interface BrowseParams {
143
201
  * scoped page is still a full page and `cursor` still means another eligible
144
202
  * row exists.
145
203
  */
146
- corpusScope?: OpenEnum<"all" | "materialBankOrderable">;
204
+ corpusScope?: CorpusScope;
205
+ /**
206
+ * Sort along ONE attribute's declared value ladder instead of a `sort` word —
207
+ * the same field, ladder and refusals `search` takes. Sent as one JSON-encoded
208
+ * `orderBy=` parameter; pass the object, the client encodes it.
209
+ *
210
+ * SUPERSEDES `sort` when both are given, and the response `note` says so.
211
+ * Ordering is DECLARED, never inferred from a value's spelling: an attribute
212
+ * with no stated ladder is a 400 carrying `orderable_attributes`. Materials
213
+ * off the ladder, or carrying no value, sort LAST in both directions.
214
+ */
215
+ orderBy?: {
216
+ attributeId: string;
217
+ direction?: OpenEnum<"asc" | "desc">;
218
+ };
219
+ /**
220
+ * Attribute ids whose resolved value rides each result under `attributes`, so
221
+ * a filtered listing can display what it filtered on. Sent as one
222
+ * comma-separated `returnAttributes=` parameter.
223
+ */
224
+ returnAttributes?: readonly string[];
147
225
  }
@@ -6,7 +6,9 @@
6
6
  * budget. Same tolerant-parsing contract as the rest of the mirror — response
7
7
  * enums are widened so a new server value still type-checks.
8
8
  */
9
+ import type { CorpusScope, CorpusScopeReceipt } from "./corpus-scope-types.js";
9
10
  import type { VariantId } from "./identifiers.js";
11
+ import type { QueryUnderstanding } from "./query-understanding-types.js";
10
12
  import type { OklabTriple, OpenEnum, PaletteSwatch } from "./types.js";
11
13
  /** Coarse group + fine type of a colour match's primary product type. */
12
14
  export interface MatchCategory {
@@ -26,6 +28,12 @@ export interface ByColourMatch {
26
28
  variantId: VariantId;
27
29
  brand: string;
28
30
  product: string;
31
+ /** Record-route brand identity. */
32
+ brandSlug?: string | null;
33
+ /** Opaque MaterialGraph product-family identity. */
34
+ productId?: string | null;
35
+ /** Material Bank cart/order join key; absent when not on record. */
36
+ materialBankId?: string;
29
37
  color: string | null;
30
38
  variantName: string | null;
31
39
  imageUrl: string | null;
@@ -147,6 +155,8 @@ export interface ColourLaneReceipt {
147
155
  /** The `search/colours` response. */
148
156
  export interface ByColourResponse {
149
157
  query: {
158
+ /** Raw query provenance, when supplied. */
159
+ q?: string;
150
160
  colours: string[];
151
161
  categories?: string[];
152
162
  /**
@@ -186,14 +196,14 @@ export interface ByColourResponse {
186
196
  * counts describe the SCOPED corpus too, because the scope was applied inside
187
197
  * retrieval rather than over the page it produced.
188
198
  */
189
- corpusScope?: {
190
- scope: OpenEnum<"all" | "materialBankOrderable">;
191
- /** What the scope admits, in one sentence, for a human reader. */
192
- description: string;
193
- };
199
+ corpusScope?: CorpusScopeReceipt;
200
+ /** Shared standard-search query-understanding receipt. */
201
+ understanding?: QueryUnderstanding;
194
202
  }
195
203
  /** Params for `search/colours`. */
196
204
  export interface ByColourParams {
205
+ /** Raw material-domain query provenance for shared query understanding. */
206
+ q?: string;
197
207
  /** A single hex (with or without `#`). */
198
208
  hex?: string;
199
209
  /** A palette — repeated as one `colour` query entry each. */
@@ -235,5 +245,7 @@ export interface ByColourParams {
235
245
  * drop.
236
246
  */
237
247
  family?: string;
248
+ /** Narrow retrieval inside the server query; absent means the whole graph. */
249
+ corpusScope?: CorpusScope;
238
250
  limit?: number;
239
251
  }
@@ -33,6 +33,17 @@ export type BrowsePageFetcher = (params: BrowseParams, signal?: AbortSignal) =>
33
33
  * route and never reached it from here, so the fan deck was unreachable through
34
34
  * the published client while the server had supported it all along. When the
35
35
  * route gains a param, it gains a line HERE too, or it is not shipped.
36
+ *
37
+ * It happened again, and twice: `filters` shipped with MG-1576 and `facetCounts`
38
+ * with MG-1590, and neither had a line here. Attribute filtering — the thing
39
+ * MG-1588 spent a materialized view making fast — was therefore unreachable
40
+ * through the published client for as long as it existed on the route. Both are
41
+ * enrolled below, along with MG-1593's `orderBy` and `returnAttributes`.
42
+ *
43
+ * `filters` is a REPEATED parameter and `facetCounts` a comma list, because that
44
+ * is what the route reads: the transport appends an array once per element, so
45
+ * the array shape and the comma-joined string are each doing exactly what the
46
+ * server expects rather than being two spellings of one idea.
36
47
  */
37
48
  export declare function browseQuery(params: BrowseParams): {
38
49
  sort: BrowseParams["sort"];
@@ -41,7 +52,11 @@ export declare function browseQuery(params: BrowseParams): {
41
52
  count: BrowseParams["count"];
42
53
  types: BrowseParams["types"];
43
54
  brands: BrowseParams["brands"];
55
+ filters: string[] | undefined;
56
+ facetCounts: string | undefined;
44
57
  corpusScope: BrowseParams["corpusScope"];
58
+ orderBy: string | undefined;
59
+ returnAttributes: string | undefined;
45
60
  };
46
61
  /**
47
62
  * Walk every page from `params.cursor` onward.
package/client-browse.js CHANGED
@@ -8,6 +8,17 @@
8
8
  * route and never reached it from here, so the fan deck was unreachable through
9
9
  * the published client while the server had supported it all along. When the
10
10
  * route gains a param, it gains a line HERE too, or it is not shipped.
11
+ *
12
+ * It happened again, and twice: `filters` shipped with MG-1576 and `facetCounts`
13
+ * with MG-1590, and neither had a line here. Attribute filtering — the thing
14
+ * MG-1588 spent a materialized view making fast — was therefore unreachable
15
+ * through the published client for as long as it existed on the route. Both are
16
+ * enrolled below, along with MG-1593's `orderBy` and `returnAttributes`.
17
+ *
18
+ * `filters` is a REPEATED parameter and `facetCounts` a comma list, because that
19
+ * is what the route reads: the transport appends an array once per element, so
20
+ * the array shape and the comma-joined string are each doing exactly what the
21
+ * server expects rather than being two spellings of one idea.
11
22
  */
12
23
  export function browseQuery(params) {
13
24
  return {
@@ -17,7 +28,15 @@ export function browseQuery(params) {
17
28
  count: params.count,
18
29
  types: params.types,
19
30
  brands: params.brands,
31
+ filters: params.filters?.map((entry) => JSON.stringify(entry)),
32
+ facetCounts: params.facetCounts?.join(","),
20
33
  corpusScope: params.corpusScope,
34
+ // `orderBy` is JSON-encoded and `returnAttributes` comma-joined because that
35
+ // is what the route reads — the same two transports `filters` and
36
+ // `facetCounts` already use. Enrolled HERE at the same time they shipped on
37
+ // the route, which is the thing the three misses above did not do.
38
+ orderBy: params.orderBy ? JSON.stringify(params.orderBy) : undefined,
39
+ returnAttributes: params.returnAttributes?.join(","),
21
40
  };
22
41
  }
23
42
  /**
package/client.d.ts CHANGED
@@ -19,6 +19,7 @@ import type { MatchStreamEvent } from "./match-stream-types.js";
19
19
  import type { MatchInput, MatchResponse } from "./match-types.js";
20
20
  import type { MaterialAssetProjection } from "./material-asset-types.js";
21
21
  import type { MaterialBlockProjection } from "./material-block-types.js";
22
+ import type { MaterialQueryPlan } from "./material-query-plan-types.js";
22
23
  import type { AppendProjectEventRequest, CreateProjectRequest, ProjectEventReceipt, ProjectListResponse, ProjectState } from "./project-types.js";
23
24
  import { resolvePhrasesRequest as resolvePhrases } from "./resolve-client.js";
24
25
  import type { CapabilityManifestResponse, CreatePublicRunRequest, CreatePublicRunResponse, DrainRunEventsOptions, ListRunsOptions, PublicRunListResponse, PublicRunResponse, ResumeRunOptions, RunEvent, SourceMaterialBatchDetail, SourceMaterialBatchReceipt, StreamRunEventsOptions, UploadSourceMaterialInput } from "./run-types.js";
@@ -127,17 +128,13 @@ export declare class MaterialGraphClient {
127
128
  * {@link MaterialGraphClient.match} for a photo, {@link MaterialGraphClient.pairings}
128
129
  * for what goes WITH something, {@link MaterialGraphClient.suggest} for typeahead.
129
130
  *
130
- * The full search primitive (POST `/api/v1/search`). Layer a hard
131
- * specification gate onto a ranked (colour / visual / lexical / seed) search
132
- * via `input.filters` — each entry is a typed {@link SearchFilter} criterion:
133
- * a term match (`{ attributeId, term }`), a presence check
134
- * (`{ attributeId, hasValue: true }`), or a certification programme
135
- * (`{ attributeId: "certification_programme", term }`). This is the seam for
136
- * "a GREENGUARD-certified alternative to THIS material" (seed by `similarTo`,
137
- * gate by `filters`) or "the same fire class, with an EPD on record". Build the
138
- * filters with the `certificationProgramme` / `specTerm` / `hasSpec` helpers.
131
+ * The full POST search primitive. `input.filters` layers typed hard
132
+ * specification gates onto ranked colour/visual/lexical/seed search. Build
133
+ * them with `certificationProgramme`, `specTerm`, or `hasSpec`.
139
134
  */
140
135
  search(input: SearchInput, signal?: AbortSignal): Promise<SearchResponse>;
136
+ /** Whole materials ask → inspectable meanings and complete safe search requests. */
137
+ planQuery(query: string, signal?: AbortSignal): Promise<MaterialQueryPlan>;
141
138
  /**
142
139
  * The same question as {@link MaterialGraphClient.search}; the difference is
143
140
  * transport — a cacheable, link-shareable GET for queries that must survive in
package/client.js CHANGED
@@ -157,19 +157,17 @@ export class MaterialGraphClient {
157
157
  * {@link MaterialGraphClient.match} for a photo, {@link MaterialGraphClient.pairings}
158
158
  * for what goes WITH something, {@link MaterialGraphClient.suggest} for typeahead.
159
159
  *
160
- * The full search primitive (POST `/api/v1/search`). Layer a hard
161
- * specification gate onto a ranked (colour / visual / lexical / seed) search
162
- * via `input.filters` — each entry is a typed {@link SearchFilter} criterion:
163
- * a term match (`{ attributeId, term }`), a presence check
164
- * (`{ attributeId, hasValue: true }`), or a certification programme
165
- * (`{ attributeId: "certification_programme", term }`). This is the seam for
166
- * "a GREENGUARD-certified alternative to THIS material" (seed by `similarTo`,
167
- * gate by `filters`) or "the same fire class, with an EPD on record". Build the
168
- * filters with the `certificationProgramme` / `specTerm` / `hasSpec` helpers.
160
+ * The full POST search primitive. `input.filters` layers typed hard
161
+ * specification gates onto ranked colour/visual/lexical/seed search. Build
162
+ * them with `certificationProgramme`, `specTerm`, or `hasSpec`.
169
163
  */
170
164
  search(input, signal) {
171
165
  return this.#post("search_materials_post", input, signal);
172
166
  }
167
+ /** Whole materials ask → inspectable meanings and complete safe search requests. */
168
+ planQuery(query, signal) {
169
+ return this.#post("plan_material_query", { query }, signal);
170
+ }
173
171
  /**
174
172
  * The same question as {@link MaterialGraphClient.search}; the difference is
175
173
  * transport — a cacheable, link-shareable GET for queries that must survive in
@@ -235,6 +233,7 @@ export class MaterialGraphClient {
235
233
  */
236
234
  byColour(params, signal) {
237
235
  return this.#get("search_by_colour", {
236
+ q: params.q,
238
237
  hex: params.hex,
239
238
  colour: params.colour,
240
239
  l: params.l,
@@ -243,6 +242,7 @@ export class MaterialGraphClient {
243
242
  category: params.category,
244
243
  brand: params.brand,
245
244
  family: params.family,
245
+ corpusScope: params.corpusScope,
246
246
  limit: params.limit,
247
247
  }, signal);
248
248
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The corpus scope vocabulary, declared ONCE for the whole client.
3
+ *
4
+ * ── Why this file exists ──
5
+ * Until MG-1168 the union was spelled inline at six places across the SDK, and
6
+ * five of them had gone stale: `designShopOrderable` shipped in MG-1144 and
7
+ * reached only the search request type, so a TypeScript consumer could send the
8
+ * scope to `/search` and not to `/browse` — against a server that had accepted
9
+ * it on both since the day it landed. The narrowed RECEIPT types were worse
10
+ * than the request ones, because a caller could not even name the scope it got
11
+ * back.
12
+ *
13
+ * That is the third hop of the reachability checklist failing quietly: server
14
+ * correct, published client unable to reach the surface. One alias means the
15
+ * next member is one edit here rather than six edits nobody is reminded to
16
+ * make. `corpus-scope-parity.test.ts` in `apps/web/config` fails if this file
17
+ * falls behind `CORPUS_SCOPES`.
18
+ *
19
+ * ── Why `OpenEnum` ──
20
+ * The server may add a scope before a consumer upgrades. `OpenEnum` keeps the
21
+ * known members as autocomplete while still accepting a string the server knows
22
+ * and this build does not, so a new scope is not a compile error in a client
23
+ * that simply has not been republished yet.
24
+ */
25
+ import { type OpenEnum } from "./types.js";
26
+ /**
27
+ * Every corpus a caller may ask for.
28
+ *
29
+ * `"all"` (and absence) is the WHOLE GRAPH — every material MaterialGraph
30
+ * knows, orderable or not. Absence is never a narrower default.
31
+ *
32
+ * Three members gate on CHANNEL membership, which is what "can someone buy
33
+ * this" means: `"materialBankNaOrderable"` (Material Bank North America),
34
+ * `"materialBankEuOrderable"` (Material Bank Europe) and
35
+ * `"designShopOrderable"` (DesignShop, with the products DesignShop hides from
36
+ * browse excluded).
37
+ *
38
+ * `"materialBankOrderable"` is NOT one of them, despite the name. It selects
39
+ * materials carrying a Material Bank identity — PROVENANCE, not a storefront —
40
+ * and admits about 98% of the graph. Use it to exclude the crawl-sourced brands
41
+ * that were never in the Material Bank feed; if you mean orderable, use one of
42
+ * the three above.
43
+ */
44
+ export type CorpusScope = OpenEnum<"all" | "designShopOrderable" | "materialBankEuOrderable" | "materialBankNaOrderable" | "materialBankOrderable">;
45
+ /**
46
+ * The corpus a response AND its counts were drawn from. Present only when a
47
+ * narrowing scope applied — absence means the whole graph. Read it alongside
48
+ * the counts: a short page under a scope is a small SCOPED corpus, never a
49
+ * truncated one.
50
+ */
51
+ export interface CorpusScopeReceipt {
52
+ scope: CorpusScope;
53
+ /** What the scope admits, in one sentence, for a human reader. */
54
+ description: string;
55
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The corpus scope vocabulary, declared ONCE for the whole client.
3
+ *
4
+ * ── Why this file exists ──
5
+ * Until MG-1168 the union was spelled inline at six places across the SDK, and
6
+ * five of them had gone stale: `designShopOrderable` shipped in MG-1144 and
7
+ * reached only the search request type, so a TypeScript consumer could send the
8
+ * scope to `/search` and not to `/browse` — against a server that had accepted
9
+ * it on both since the day it landed. The narrowed RECEIPT types were worse
10
+ * than the request ones, because a caller could not even name the scope it got
11
+ * back.
12
+ *
13
+ * That is the third hop of the reachability checklist failing quietly: server
14
+ * correct, published client unable to reach the surface. One alias means the
15
+ * next member is one edit here rather than six edits nobody is reminded to
16
+ * make. `corpus-scope-parity.test.ts` in `apps/web/config` fails if this file
17
+ * falls behind `CORPUS_SCOPES`.
18
+ *
19
+ * ── Why `OpenEnum` ──
20
+ * The server may add a scope before a consumer upgrades. `OpenEnum` keeps the
21
+ * known members as autocomplete while still accepting a string the server knows
22
+ * and this build does not, so a new scope is not a compile error in a client
23
+ * that simply has not been republished yet.
24
+ */
25
+ import {} from "./types.js";
package/demand-types.d.ts CHANGED
@@ -95,6 +95,26 @@ export interface CoordinateCompanion {
95
95
  imageUrl: string | null;
96
96
  brandSlug: string;
97
97
  productId: ProductId;
98
+ /**
99
+ * How this companion relates to the SEED SET — the closest relation that
100
+ * applies to any seed.
101
+ *
102
+ * `companion` is foreign to every seed: a maker the caller did not pick.
103
+ * `brand_sibling` shares a brand with some seed. `colourway` is a seed's own
104
+ * product restyled, and completes nothing.
105
+ *
106
+ * READ IT BEFORE CLAIMING BREADTH. Measured across the live edges (MG-1007),
107
+ * 70.6% are same-brand and 24.3% are the seed's own product in another
108
+ * colourway; on a two-seed rail, 27 of 40 companions came from the seeds' own
109
+ * two brands. A surface headed "makers you haven't picked" that does not
110
+ * filter on this is making a claim the evidence does not support — the
111
+ * measured case showed two distinct makers under a heading promising seven.
112
+ *
113
+ * The server has always sent this field; it was absent from this interface
114
+ * until 2026-08-23, so consumers could not see it and hand-rolled the same
115
+ * comparison against `brandSlug` themselves.
116
+ */
117
+ relation: "companion" | "brand_sibling" | "colourway";
98
118
  totalLift: number;
99
119
  liftLabel: string;
100
120
  seedMatchCount: number;
package/errors.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface ProblemDetail {
17
17
  suggestions?: string[];
18
18
  doc_uri?: string;
19
19
  issues?: SanitizedIssue[];
20
+ /**
21
+ * MaterialGraph's documented RFC 7807 extension members, sanitized by the
22
+ * same rules as every other nested value here. Absent when the response did
23
+ * not carry them. See {@link MATERIALGRAPH_PROBLEM_EXTENSIONS}.
24
+ */
25
+ readonly [extension: string]: unknown;
20
26
  }
21
27
  /** Normalized residual `{ error }` body. Unknown response fields are discarded. */
22
28
  export interface SimpleErrorBody {
@@ -61,6 +67,29 @@ interface TransportErrorInput extends MaterialGraphRequestMetadata {
61
67
  cause: unknown;
62
68
  callerSignal?: AbortSignal;
63
69
  }
70
+ /**
71
+ * MaterialGraph's documented problem EXTENSION members — the only non-standard
72
+ * keys this client retains.
73
+ *
74
+ * ── Why an allow-list and not "keep everything" ──
75
+ * Dropping unknown members is a deliberate decision here, pinned by a test that
76
+ * sends `unknown_top_level: "must-not-be-retained"` and asserts it is gone. A
77
+ * client should not echo arbitrary server-supplied keys into an object the
78
+ * caller trusts. That decision stands.
79
+ *
80
+ * ── Why not "drop everything", which is what it used to do ──
81
+ * MaterialGraph sends these members precisely so a caller can BRANCH on a
82
+ * refusal rather than parse its prose, and dropping them silently forced the
83
+ * opposite. A consumer reported it on 2026-08-21: they received
84
+ * `populated_alternatives` on the wire, found it absent from the thrown error,
85
+ * and were left matching a regex against `detail` — the exact coupling the
86
+ * field was added to remove. All ten members below were unreachable.
87
+ *
88
+ * So: the server's own contract is retained, and nothing else is. Adding a
89
+ * member to a problem response means adding it here too, and
90
+ * `problem-extension-parity.test.ts` fails when the two disagree.
91
+ */
92
+ export declare const MATERIALGRAPH_PROBLEM_EXTENSIONS: readonly ["content_length", "did_you_mean", "empty_brands", "limit_bytes", "missing_env", "orderable_attributes", "populated_alternatives", "unknown_attributes", "unknown_brands", "unknown_fields", "unorderable_attribute", "valid_group_keys", "valid_type_keys"];
64
93
  /** The one error class thrown by all SDK transport and HTTP failures. */
65
94
  export declare class MaterialGraphError extends Error {
66
95
  readonly kind: MaterialGraphErrorKind;
package/errors.js CHANGED
@@ -1,5 +1,43 @@
1
1
  /** Error and rate-limit contracts for the retry-free MaterialGraph SDK. */
2
2
  const RETRIABLE_STATUS = new Set([502, 503, 504]);
3
+ /**
4
+ * MaterialGraph's documented problem EXTENSION members — the only non-standard
5
+ * keys this client retains.
6
+ *
7
+ * ── Why an allow-list and not "keep everything" ──
8
+ * Dropping unknown members is a deliberate decision here, pinned by a test that
9
+ * sends `unknown_top_level: "must-not-be-retained"` and asserts it is gone. A
10
+ * client should not echo arbitrary server-supplied keys into an object the
11
+ * caller trusts. That decision stands.
12
+ *
13
+ * ── Why not "drop everything", which is what it used to do ──
14
+ * MaterialGraph sends these members precisely so a caller can BRANCH on a
15
+ * refusal rather than parse its prose, and dropping them silently forced the
16
+ * opposite. A consumer reported it on 2026-08-21: they received
17
+ * `populated_alternatives` on the wire, found it absent from the thrown error,
18
+ * and were left matching a regex against `detail` — the exact coupling the
19
+ * field was added to remove. All ten members below were unreachable.
20
+ *
21
+ * So: the server's own contract is retained, and nothing else is. Adding a
22
+ * member to a problem response means adding it here too, and
23
+ * `problem-extension-parity.test.ts` fails when the two disagree.
24
+ */
25
+ export const MATERIALGRAPH_PROBLEM_EXTENSIONS = [
26
+ "content_length",
27
+ "did_you_mean",
28
+ "empty_brands",
29
+ "limit_bytes",
30
+ "missing_env",
31
+ "orderable_attributes",
32
+ "populated_alternatives",
33
+ "unknown_attributes",
34
+ "unknown_brands",
35
+ "unknown_fields",
36
+ "unorderable_attribute",
37
+ "valid_group_keys",
38
+ "valid_type_keys",
39
+ ];
40
+ const PROBLEM_EXTENSION_KEYS = new Set(MATERIALGRAPH_PROBLEM_EXTENSIONS);
3
41
  const MAX_UNEXPECTED_TEXT = 768;
4
42
  const MAX_EXTENSION_DEPTH = 8;
5
43
  const MAX_EXTENSION_ITEMS = 100;
@@ -89,7 +127,12 @@ function normalizeProblem(body, secrets) {
89
127
  }
90
128
  const suggestions = sanitizeSuggestions(body.suggestions, secrets);
91
129
  const issues = sanitizeIssues(body.issues, secrets);
130
+ // Extensions FIRST so a malformed one can never shadow a standard member
131
+ // below. `sanitizeRecord` is the existing redaction and depth/item caps —
132
+ // this is that machinery finally being reached, not a second sanitizer.
133
+ const extensions = sanitizeRecord(Object.fromEntries(Object.entries(body).filter(([key]) => PROBLEM_EXTENSION_KEYS.has(key))), secrets, 0);
92
134
  return {
135
+ ...extensions,
93
136
  type: safeString(body.type, secrets),
94
137
  title: safeString(body.title, secrets),
95
138
  status: body.status,
@@ -1,3 +1,4 @@
1
+ import type { CorpusScope, CorpusScopeReceipt } from "./corpus-scope-types.js";
1
2
  import type { VariantId } from "./identifiers.js";
2
3
  export type GraphOperator = "colourways" | "same_category" | "still_listed" | "looks_like" | "same_colour" | "pairs_with" | "often_specified_with" | "substitutes_for" | "more_durable" | "outdoor_suitable" | "lower_cost" | "more_sustainable";
3
4
  export interface GraphOperatorAvailability {
@@ -14,7 +15,7 @@ export interface GraphOperatorEvidence {
14
15
  }
15
16
  export interface CandidateReadInput {
16
17
  operator: "looks_like";
17
- corpusScope?: "all" | "materialBankOrderable";
18
+ corpusScope?: CorpusScope;
18
19
  limit?: number;
19
20
  excludeVariantIds?: VariantId[];
20
21
  }
@@ -62,10 +63,7 @@ export interface CandidateSeedEvidence {
62
63
  }
63
64
  export interface CandidateReadReceipt {
64
65
  operator: "looks_like";
65
- corpusScope: {
66
- scope: "all" | "materialBankOrderable";
67
- description: string;
68
- } | null;
66
+ corpusScope: CorpusScopeReceipt | null;
69
67
  requestedLimit: number;
70
68
  returnedCount: number;
71
69
  exclusions: {
package/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { PROJECT_EVENT_KINDS } from "./project-types.js";
14
14
  export type * from "./project-types.js";
15
15
  export { COMPOSITION_PHASE_EVENT_TYPES, COMPOSITION_PROGRAMME_EVENT_TYPES, COMPOSITION_REVIEW_CRITERIA_EVENT_TYPES, parseRunEvent, } from "./run-events.js";
16
16
  export type * from "./run-events.js";
17
- export { MaterialGraphError } from "./errors.js";
17
+ export { MATERIALGRAPH_PROBLEM_EXTENSIONS, MaterialGraphError, } from "./errors.js";
18
18
  export type { MaterialGraphErrorInit, MaterialGraphErrorKind, MaterialGraphRequestMetadata, ProblemDetail, RateLimit, SanitizedErrorValue, SanitizedIssue, SimpleErrorBody, } from "./errors.js";
19
19
  export type * from "./types.js";
20
20
  export type * from "./match-types.js";
@@ -28,6 +28,8 @@ export { ColoursClient } from "./colours-client.js";
28
28
  export type { ColoursTransport } from "./colours-client.js";
29
29
  export type * from "./colour-aggregate-types.js";
30
30
  export type * from "./palette-search-types.js";
31
+ export type * from "./query-understanding-types.js";
32
+ export type * from "./material-query-plan-types.js";
31
33
  export type * from "./association-types.js";
32
34
  export type * from "./brand-types.js";
33
35
  export type * from "./fan-deck-types.js";
package/index.js CHANGED
@@ -4,7 +4,7 @@ export { TERMINAL_RUN_EVENT_TYPES } from "./run-types.js";
4
4
  export { VARY_AXES } from "./composition-types.js";
5
5
  export { PROJECT_EVENT_KINDS } from "./project-types.js";
6
6
  export { COMPOSITION_PHASE_EVENT_TYPES, COMPOSITION_PROGRAMME_EVENT_TYPES, COMPOSITION_REVIEW_CRITERIA_EVENT_TYPES, parseRunEvent, } from "./run-events.js";
7
- export { MaterialGraphError } from "./errors.js";
7
+ export { MATERIALGRAPH_PROBLEM_EXTENSIONS, MaterialGraphError, } from "./errors.js";
8
8
  export { MATCH_IMAGE_TYPES, MATCH_OUTCOMES, MATCH_MATERIAL_FUNCTIONS, MATCH_STREAM_EVENT_TYPES, MATCH_SURFACE_STATUSES, MATCH_WORKFLOW_STEPS, } from "./match-stream-types.js";
9
9
  export { CodesClient } from "./codes-client.js";
10
10
  export { ColoursClient } from "./colours-client.js";
@@ -0,0 +1,154 @@
1
+ /** Hand-mirrored wire types for `POST /api/v1/search/plan`. */
2
+ import type { QuerySense } from "./query-understanding-types.js";
3
+ import type { ResolvedMeaning } from "./resolve-types.js";
4
+ import type { SearchInput } from "./search-request-types.js";
5
+ import type { OpenEnum } from "./types.js";
6
+ export type MaterialQueryVocabularyKind = OpenEnum<"attribute" | "dictionary_term" | "inline_enum" | "product_type" | "product_type_group">;
7
+ export interface MaterialQuerySpan {
8
+ start: number;
9
+ end: number;
10
+ text: string;
11
+ }
12
+ export interface MaterialQueryOwner {
13
+ attributeId: string;
14
+ attributeName: string;
15
+ }
16
+ export interface MaterialQueryLifecycle {
17
+ status: string;
18
+ introducedIn?: string;
19
+ deprecatedIn?: string;
20
+ supersededBy?: string;
21
+ }
22
+ export type MaterialQueryOntologyContext = {
23
+ description: string;
24
+ valueKind: string;
25
+ valueDictionaryId?: string;
26
+ } | {
27
+ dictionaryId: string;
28
+ dictionaryName: string;
29
+ owningAttributes: Array<{
30
+ id: string;
31
+ name: string;
32
+ }>;
33
+ lifecycle: MaterialQueryLifecycle;
34
+ } | {
35
+ attributeId: string;
36
+ attributeName: string;
37
+ } | {
38
+ description: string;
39
+ lifecycle: MaterialQueryLifecycle;
40
+ parentProductTypeGroupId?: string;
41
+ } | {
42
+ description: string;
43
+ };
44
+ export interface MaterialQueryApplicability {
45
+ status: OpenEnum<"all" | "some" | "none" | "unknown">;
46
+ categoryId?: string;
47
+ productTypeIds: string[];
48
+ applicableProductTypeIds: string[];
49
+ }
50
+ export interface MaterialQueryCoverage {
51
+ variants: number;
52
+ basis: OpenEnum<"facet_index">;
53
+ scope: OpenEnum<"corpus">;
54
+ }
55
+ export interface MaterialQueryReachability {
56
+ status: OpenEnum<"positive" | "zero" | "unknown">;
57
+ categoryId: string;
58
+ basis: OpenEnum<"bounded_probe" | "unmeasured">;
59
+ }
60
+ export interface MaterialQueryCandidate {
61
+ kind: MaterialQueryVocabularyKind;
62
+ subjectType: MaterialQueryVocabularyKind;
63
+ subjectId: string;
64
+ label: string;
65
+ context: MaterialQueryOntologyContext;
66
+ score: number;
67
+ tier: OpenEnum<"exact_id" | "exact_alias" | "lexical" | "semantic">;
68
+ evidence: {
69
+ source: OpenEnum<"registry" | "query_domain_embedding">;
70
+ span: MaterialQuerySpan;
71
+ };
72
+ outcome: OpenEnum<"recognized" | "suggested" | "executable" | "ambiguous" | "refused">;
73
+ owners: MaterialQueryOwner[];
74
+ selectedOwner?: MaterialQueryOwner;
75
+ applicability?: MaterialQueryApplicability;
76
+ coverage?: MaterialQueryCoverage;
77
+ reachability?: MaterialQueryReachability;
78
+ reason?: string;
79
+ }
80
+ export interface MaterialQueryCriterion {
81
+ source: {
82
+ subjectType: MaterialQueryVocabularyKind;
83
+ subjectId: string;
84
+ span: MaterialQuerySpan;
85
+ };
86
+ attribute: {
87
+ id: string;
88
+ name: string;
89
+ };
90
+ ownerAlternatives: MaterialQueryOwner[];
91
+ operator: OpenEnum<"term" | "presence" | "numeric_range">;
92
+ term?: string;
93
+ min?: number;
94
+ max?: number;
95
+ coverage: MaterialQueryCoverage;
96
+ applicability: MaterialQueryApplicability;
97
+ reachability?: MaterialQueryReachability;
98
+ }
99
+ export type MaterialQueryStandardMeaning = ResolvedMeaning | QuerySense;
100
+ export type MaterialQueryAmbiguityAlternative = (MaterialQueryOwner & {
101
+ kind: OpenEnum<"owner">;
102
+ }) | {
103
+ kind: OpenEnum<"subject">;
104
+ subjectType: MaterialQueryVocabularyKind;
105
+ subjectId: string;
106
+ label: string;
107
+ } | {
108
+ kind: OpenEnum<"standard_meaning">;
109
+ meaning: MaterialQueryStandardMeaning;
110
+ };
111
+ export interface MaterialQueryPlan {
112
+ version: OpenEnum<"1">;
113
+ verdict: OpenEnum<"executable" | "partial" | "refused">;
114
+ rawQuery: string;
115
+ normalisedQuery: string;
116
+ registry: {
117
+ corpusSize: number;
118
+ contentHash: string;
119
+ };
120
+ candidates: MaterialQueryCandidate[];
121
+ category: {
122
+ key: string;
123
+ name: string;
124
+ level: OpenEnum<"group" | "type">;
125
+ parentKey: string | null;
126
+ source: {
127
+ subjectType: MaterialQueryVocabularyKind;
128
+ subjectId: string;
129
+ span: MaterialQuerySpan;
130
+ };
131
+ } | null;
132
+ criteria: MaterialQueryCriterion[];
133
+ colour: {
134
+ family?: string;
135
+ undertone?: string;
136
+ } | null;
137
+ residualText: string;
138
+ unspentSpans: Array<MaterialQuerySpan & {
139
+ reason: OpenEnum<"residual" | "ambiguous" | "refused">;
140
+ }>;
141
+ ambiguities: Array<{
142
+ subjectType: MaterialQueryVocabularyKind;
143
+ subjectId: string;
144
+ span: MaterialQuerySpan;
145
+ reason: string;
146
+ alternatives: MaterialQueryAmbiguityAlternative[];
147
+ }>;
148
+ standardMeanings: Array<{
149
+ span: MaterialQuerySpan;
150
+ meanings: MaterialQueryStandardMeaning[];
151
+ }>;
152
+ /** Complete ordinary-search requests authored by MaterialGraph. Execute verbatim. */
153
+ requests: SearchInput[];
154
+ }
@@ -0,0 +1 @@
1
+ export {};
package/operations.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export declare const MATERIALGRAPH_API_VERSION = "0.7.0";
2
- declare const OPERATION_DEFINITIONS: readonly [readonly ["search", "POST", "/api/v1/search", "search_materials_post"], readonly ["searchGet", "GET", "/api/v1/search", "search_materials_get"], readonly ["match", "POST", "/api/v1/search/match", "match_material"], readonly ["byColour", "GET", "/api/v1/search/colours", "search_by_colour"], readonly ["material", "GET", "/api/v1/materials/{variantId}", "get_material"], readonly ["siblings", "GET", "/api/v1/materials/{variantId}/siblings", "get_material_siblings"], readonly ["materialBlocks", "GET", "/api/v1/materials/{variantId}/blocks", "list_material_blocks"], readonly ["materialAssets", "GET", "/api/v1/materials/{variantId}/assets", "list_material_assets"], readonly ["exploration.operators", "GET", "/api/v1/materials/{variantId}/operators", "list_material_operators"], readonly ["exploration.candidates", "POST", "/api/v1/materials/{variantId}/candidates", "read_material_candidates"], readonly ["schema", "GET", "/api/v1/schema/blocks", "list_schema_blocks"], readonly ["schema", "GET", "/api/v1/schema/attributes", "list_schema_attributes"], readonly ["schema", "GET", "/api/v1/schema/verticals", "list_schema_verticals"], readonly ["schema", "GET", "/api/v1/schema/product-types", "list_schema_product_types"], readonly ["schema", "GET", "/api/v1/schema/units", "list_schema_units"], readonly ["schema", "GET", "/api/v1/schema/value-dictionaries", "list_schema_value_dictionaries"], readonly ["schema", "GET", "/api/v1/schema/source-requirements", "list_schema_source_requirements"], readonly ["schema", "GET", "/api/v1/schema/product-type-templates", "list_schema_product_type_templates"], readonly ["schema", "GET", "/api/v1/schema/mappings", "list_schema_mappings"], readonly ["pairings", "GET", "/api/v1/materials/{variantId}/pairings", "get_material_pairings"], readonly ["associatedFinishes", "GET", "/api/v1/associated/finishes", "list_associated_finishes"], readonly ["associatedProducts", "GET", "/api/v1/associated/products", "list_associated_products"], readonly ["materials", "POST", "/api/v1/materials/batch", "get_materials_batch_post"], readonly ["materialsCached", "GET", "/api/v1/materials/batch", "get_materials_batch_get"], readonly ["browse", "GET", "/api/v1/search/browse", "search_browse_by_colour"], readonly ["colourFacets", "GET", "/api/v1/colours/facets", "get_colour_facets"], readonly ["colours.profiles", "GET", "/api/v1/colours/profiles", "get_category_colour_profiles"], readonly ["colours.stats", "GET", "/api/v1/colours/stats", "get_colour_stats"], readonly ["resolveColour", "GET", "/api/v1/search/colours/resolve", "resolve_colour_name"], readonly ["suggestColour", "GET", "/api/v1/search/colours/suggest", "suggest_colour_names"], readonly ["suggest", "GET", "/api/v1/search/suggest", "search_suggest"], readonly ["resolveAttribute", "GET", "/api/v1/catalog/attributes/resolve", "catalog_resolve_attribute"], readonly ["resolveAttributeValue", "GET", "/api/v1/catalog/attributes/{attributeId}/values/resolve", "catalog_resolve_value"], readonly ["searchVocabulary", "GET", "/api/v1/catalog/vocabulary/search", "catalog_vocabulary_search"], readonly ["resolve", "POST", "/api/v1/resolve", "resolve_phrases"], readonly ["bySpec", "GET", "/api/v1/search/specifications", "catalog_by_spec"], readonly ["taxonomy", "GET", "/api/v1/taxonomy/categories", "get_category_taxonomy"], readonly ["whoami", "GET", "/api/v1/whoami", "whoami"], readonly ["coordinate", "POST", "/api/v1/pairings/coordinate", "coordinate_materials"], readonly ["demand", "GET", "/api/v1/demand", "get_material_demand"], readonly ["colours.palette", "POST", "/api/v1/search/palettes", "search_by_palette"], readonly ["extractPalette", "POST", "/api/v1/search/palettes/extract", "extract_image_palette"], readonly ["colours.gradient", "POST", "/api/v1/search/gradients", "search_by_gradient"], readonly ["specificationFacets", "GET", "/api/v1/catalog/specifications/facets", "list_spec_facets"], readonly ["attributeValues", "GET", "/api/v1/catalog/attributes/{attributeId}/values", "list_attribute_values"], readonly ["applicableSpecs", "GET", "/api/v1/taxonomy/categories/{key}/specs", "get_applicable_specs"], readonly ["glossary", "GET", "/api/v1/glossary/{term}", "explain_material_term"], readonly ["brands", "GET", "/api/v1/brands", "list_brands"], readonly ["brand", "GET", "/api/v1/brands/{brandSlug}", "get_brand_profile"], readonly ["brandColourDeck", "GET", "/api/v1/brands/{brandSlug}/colours", "brand_colour_deck"], readonly ["uploadSourceMaterial", "POST", "/api/v1/source-material", "upload_source_material"], readonly ["sourceMaterialBatch", "GET", "/api/v1/source-material/{batchId}", "get_source_material_batch"], readonly ["sourceMaterialComments", "GET", "/api/v1/source-material/{batchId}/sources/{sourceId}/comments", "list_source_material_comments"], readonly ["sourceMaterialComments", "POST", "/api/v1/source-material/{batchId}/sources/{sourceId}/comments", "post_source_material_comment"], readonly ["capabilities", "GET", "/api/v1/capabilities", "list_public_capabilities"], readonly ["createRun", "POST", "/api/v1/runs", "create_public_run"], readonly ["listRuns", "GET", "/api/v1/runs", "list_public_runs"], readonly ["run", "GET", "/api/v1/runs/{runId}", "get_public_run"], readonly ["runSchedule", "GET", "/api/v1/runs/{runId}/schedule", "get_run_finish_schedule"], readonly ["streamRunEvents", "GET", "/api/v1/runs/{runId}/events", "list_public_run_events"], readonly ["resumeRun", "POST", "/api/v1/runs/{runId}/resume", "resume_public_run"], readonly ["cancelRun", "POST", "/api/v1/runs/{runId}/cancel", "cancel_public_run"], readonly ["varyScheme", "POST", "/api/v1/schemes/vary", "vary_schemes"], readonly ["slotPools", "POST", "/api/v1/schemes/pools", "slot_pools"], readonly ["createProject", "POST", "/api/v1/projects", "create_box_project"], readonly ["listProjects", "GET", "/api/v1/projects", "list_box_projects"], readonly ["getProject", "GET", "/api/v1/projects/{projectId}", "get_box_project"], readonly ["appendProjectEvent", "POST", "/api/v1/projects/{projectId}/events", "append_box_project_event"], readonly ["demandBatch", "POST", "/api/v1/demand/batch", "get_demand_batch"], readonly ["codeAdvisory", "POST", "/api/v1/codes/advisory", "resolveCodeAdvisory"], readonly ["codes.context", "GET", "/api/v1/codes/context", "list_code_context"], readonly ["codes.requirements", "GET", "/api/v1/codes/requirements", "list_code_requirements"]];
2
+ declare const OPERATION_DEFINITIONS: readonly [readonly ["search", "POST", "/api/v1/search", "search_materials_post"], readonly ["planQuery", "POST", "/api/v1/search/plan", "plan_material_query"], readonly ["searchGet", "GET", "/api/v1/search", "search_materials_get"], readonly ["match", "POST", "/api/v1/search/match", "match_material"], readonly ["byColour", "GET", "/api/v1/search/colours", "search_by_colour"], readonly ["material", "GET", "/api/v1/materials/{variantId}", "get_material"], readonly ["siblings", "GET", "/api/v1/materials/{variantId}/siblings", "get_material_siblings"], readonly ["materialBlocks", "GET", "/api/v1/materials/{variantId}/blocks", "list_material_blocks"], readonly ["materialAssets", "GET", "/api/v1/materials/{variantId}/assets", "list_material_assets"], readonly ["exploration.operators", "GET", "/api/v1/materials/{variantId}/operators", "list_material_operators"], readonly ["exploration.candidates", "POST", "/api/v1/materials/{variantId}/candidates", "read_material_candidates"], readonly ["schema", "GET", "/api/v1/schema/blocks", "list_schema_blocks"], readonly ["schema", "GET", "/api/v1/schema/attributes", "list_schema_attributes"], readonly ["schema", "GET", "/api/v1/schema/verticals", "list_schema_verticals"], readonly ["schema", "GET", "/api/v1/schema/product-types", "list_schema_product_types"], readonly ["schema", "GET", "/api/v1/schema/units", "list_schema_units"], readonly ["schema", "GET", "/api/v1/schema/value-dictionaries", "list_schema_value_dictionaries"], readonly ["schema", "GET", "/api/v1/schema/source-requirements", "list_schema_source_requirements"], readonly ["schema", "GET", "/api/v1/schema/product-type-templates", "list_schema_product_type_templates"], readonly ["schema", "GET", "/api/v1/schema/mappings", "list_schema_mappings"], readonly ["pairings", "GET", "/api/v1/materials/{variantId}/pairings", "get_material_pairings"], readonly ["associatedFinishes", "GET", "/api/v1/associated/finishes", "list_associated_finishes"], readonly ["associatedProducts", "GET", "/api/v1/associated/products", "list_associated_products"], readonly ["materials", "POST", "/api/v1/materials/batch", "get_materials_batch_post"], readonly ["materialsCached", "GET", "/api/v1/materials/batch", "get_materials_batch_get"], readonly ["browse", "GET", "/api/v1/search/browse", "search_browse_by_colour"], readonly ["colourFacets", "GET", "/api/v1/colours/facets", "get_colour_facets"], readonly ["colours.profiles", "GET", "/api/v1/colours/profiles", "get_category_colour_profiles"], readonly ["colours.stats", "GET", "/api/v1/colours/stats", "get_colour_stats"], readonly ["resolveColour", "GET", "/api/v1/search/colours/resolve", "resolve_colour_name"], readonly ["suggestColour", "GET", "/api/v1/search/colours/suggest", "suggest_colour_names"], readonly ["suggest", "GET", "/api/v1/search/suggest", "search_suggest"], readonly ["resolveAttribute", "GET", "/api/v1/catalog/attributes/resolve", "catalog_resolve_attribute"], readonly ["resolveAttributeValue", "GET", "/api/v1/catalog/attributes/{attributeId}/values/resolve", "catalog_resolve_value"], readonly ["searchVocabulary", "GET", "/api/v1/catalog/vocabulary/search", "catalog_vocabulary_search"], readonly ["resolve", "POST", "/api/v1/resolve", "resolve_phrases"], readonly ["bySpec", "GET", "/api/v1/search/specifications", "catalog_by_spec"], readonly ["taxonomy", "GET", "/api/v1/taxonomy/categories", "get_category_taxonomy"], readonly ["whoami", "GET", "/api/v1/whoami", "whoami"], readonly ["coordinate", "POST", "/api/v1/pairings/coordinate", "coordinate_materials"], readonly ["demand", "GET", "/api/v1/demand", "get_material_demand"], readonly ["colours.palette", "POST", "/api/v1/search/palettes", "search_by_palette"], readonly ["extractPalette", "POST", "/api/v1/search/palettes/extract", "extract_image_palette"], readonly ["colours.gradient", "POST", "/api/v1/search/gradients", "search_by_gradient"], readonly ["specificationFacets", "GET", "/api/v1/catalog/specifications/facets", "list_spec_facets"], readonly ["attributeValues", "GET", "/api/v1/catalog/attributes/{attributeId}/values", "list_attribute_values"], readonly ["applicableSpecs", "GET", "/api/v1/taxonomy/categories/{key}/specs", "get_applicable_specs"], readonly ["glossary", "GET", "/api/v1/glossary/{term}", "explain_material_term"], readonly ["brands", "GET", "/api/v1/brands", "list_brands"], readonly ["brand", "GET", "/api/v1/brands/{brandSlug}", "get_brand_profile"], readonly ["brandColourDeck", "GET", "/api/v1/brands/{brandSlug}/colours", "brand_colour_deck"], readonly ["uploadSourceMaterial", "POST", "/api/v1/source-material", "upload_source_material"], readonly ["sourceMaterialBatch", "GET", "/api/v1/source-material/{batchId}", "get_source_material_batch"], readonly ["sourceMaterialComments", "GET", "/api/v1/source-material/{batchId}/sources/{sourceId}/comments", "list_source_material_comments"], readonly ["sourceMaterialComments", "POST", "/api/v1/source-material/{batchId}/sources/{sourceId}/comments", "post_source_material_comment"], readonly ["capabilities", "GET", "/api/v1/capabilities", "list_public_capabilities"], readonly ["createRun", "POST", "/api/v1/runs", "create_public_run"], readonly ["listRuns", "GET", "/api/v1/runs", "list_public_runs"], readonly ["run", "GET", "/api/v1/runs/{runId}", "get_public_run"], readonly ["runSchedule", "GET", "/api/v1/runs/{runId}/schedule", "get_run_finish_schedule"], readonly ["streamRunEvents", "GET", "/api/v1/runs/{runId}/events", "list_public_run_events"], readonly ["resumeRun", "POST", "/api/v1/runs/{runId}/resume", "resume_public_run"], readonly ["cancelRun", "POST", "/api/v1/runs/{runId}/cancel", "cancel_public_run"], readonly ["varyScheme", "POST", "/api/v1/schemes/vary", "vary_schemes"], readonly ["slotPools", "POST", "/api/v1/schemes/pools", "slot_pools"], readonly ["createProject", "POST", "/api/v1/projects", "create_box_project"], readonly ["listProjects", "GET", "/api/v1/projects", "list_box_projects"], readonly ["getProject", "GET", "/api/v1/projects/{projectId}", "get_box_project"], readonly ["appendProjectEvent", "POST", "/api/v1/projects/{projectId}/events", "append_box_project_event"], readonly ["demandBatch", "POST", "/api/v1/demand/batch", "get_demand_batch"], readonly ["codeAdvisory", "POST", "/api/v1/codes/advisory", "resolveCodeAdvisory"], readonly ["codes.context", "GET", "/api/v1/codes/context", "list_code_context"], readonly ["codes.requirements", "GET", "/api/v1/codes/requirements", "list_code_requirements"]];
3
3
  export type SdkOperationId = (typeof OPERATION_DEFINITIONS)[number][3];
4
4
  export type SdkOperation = Readonly<{
5
5
  clientMethod: (typeof OPERATION_DEFINITIONS)[number][0];
package/operations.js CHANGED
@@ -2,6 +2,7 @@ import { MaterialGraphError } from "./errors.js";
2
2
  export const MATERIALGRAPH_API_VERSION = "0.7.0";
3
3
  const OPERATION_DEFINITIONS = [
4
4
  ["search", "POST", "/api/v1/search", "search_materials_post"],
5
+ ["planQuery", "POST", "/api/v1/search/plan", "plan_material_query"],
5
6
  ["searchGet", "GET", "/api/v1/search", "search_materials_get"],
6
7
  ["match", "POST", "/api/v1/search/match", "match_material"],
7
8
  ["byColour", "GET", "/api/v1/search/colours", "search_by_colour"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instruments/materialgraph",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,38 @@
1
+ import type { OpenEnum } from "./types.js";
2
+ export type QuerySenseKind = OpenEnum<"brand" | "product" | "paint_identity" | "category" | "material" | "colour" | "attribute" | "attribute_value" | "certification">;
3
+ export interface QuerySense {
4
+ id: string;
5
+ kind: QuerySenseKind;
6
+ label: string;
7
+ outcome: OpenEnum<"applied" | "ambiguous" | "recognized" | "refused">;
8
+ effect: OpenEnum<"restrict" | "prioritize" | "rerank" | "offer" | "none">;
9
+ confidence: number;
10
+ evidence: {
11
+ source: string;
12
+ matchedText: string;
13
+ span?: {
14
+ start: number;
15
+ end: number;
16
+ };
17
+ };
18
+ entity?: {
19
+ brandSlug?: string;
20
+ productId?: string;
21
+ variantId?: string;
22
+ materialBankId?: string;
23
+ productTypeId?: string;
24
+ paintCode?: string;
25
+ hex?: string;
26
+ };
27
+ reason?: string;
28
+ }
29
+ /** Inspectable interpretation of the raw query by ordinary MaterialGraph search. */
30
+ export interface QueryUnderstanding {
31
+ version: OpenEnum<"1">;
32
+ rawQuery: string;
33
+ normalisedQuery: string;
34
+ residualText: string;
35
+ status: OpenEnum<"resolved" | "ambiguous" | "partial" | "unresolved">;
36
+ mode: OpenEnum<"image" | "colour" | "text" | "lexical" | "entity-browse">;
37
+ senses: QuerySense[];
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -28,14 +28,21 @@ export interface SearchFilterTrust {
28
28
  minConfidence?: number;
29
29
  /**
30
30
  * Demand an EXACT source basis (closed enum — an unknown basis is rejected).
31
- * Roughly strongest to weakest as evidence:
32
- * - `tested` — a measured result. The strongest claim available.
31
+ * Strongest to weakest as evidence:
33
32
  * - `declared` — the manufacturer stated it. Attributable, not independent.
34
33
  * - `catalog_feed` — it arrived in a catalog feed. Trusted at ingest, unreviewed.
35
34
  * - `derived` — MaterialGraph inferred it. Ours, not the manufacturer's — never
36
35
  * present a `derived` value as something the brand claims.
36
+ *
37
+ * There is NO `tested` basis. This docblock advertised one as "the strongest
38
+ * claim available" through 0.12.0, and the union listed `declared` twice in
39
+ * its place — so a caller reading the documentation would write a value the
40
+ * server rejects with a 400, and the type would not stop them because the
41
+ * fourth arm was a duplicate rather than the value described.
42
+ * `packages/schema/src/source-basis.test.ts` asserts `tested` parses false;
43
+ * its absence is deliberate, not an omission.
37
44
  */
38
- sourceBasis?: "catalog_feed" | "declared" | "derived" | "declared";
45
+ sourceBasis?: "catalog_feed" | "declared" | "derived";
39
46
  /** Fold the attribute's own registry confidence requirement in, server-side. */
40
47
  meetsRequirement?: true;
41
48
  }
@@ -49,6 +56,22 @@ export type SearchFilter = {
49
56
  attributeId: string;
50
57
  term: string;
51
58
  trust?: SearchFilterTrust;
59
+ }
60
+ /**
61
+ * TERM SET — the attribute must equal any ONE of these. OR within the entry;
62
+ * AND still holds between entries, so a filter rail composes the same way
63
+ * whatever it is filtering on.
64
+ *
65
+ * The server has accepted this since the term-set arm shipped, and a consumer
66
+ * has been sending it successfully on the wire — but it was missing from this
67
+ * union through 0.12.0, so multi-value filtering type-checked nowhere while
68
+ * working everywhere. Brand multi-select is the common case:
69
+ * `{attributeId: "brand", terms: ["kvadrat", "interface"]}`.
70
+ */
71
+ | {
72
+ attributeId: string;
73
+ terms: string[];
74
+ trust?: SearchFilterTrust;
52
75
  } | {
53
76
  attributeId: string;
54
77
  hasValue: true;
@@ -5,6 +5,7 @@
5
5
  * repo's source-size gate; every name here is re-exported from `types.ts`, so
6
6
  * no import path changes.
7
7
  */
8
+ import type { CorpusScope } from "./corpus-scope-types.js";
8
9
  import type { Cursor, VariantId } from "./identifiers.js";
9
10
  import type { SearchFilter } from "./search-refinement-types.js";
10
11
  import type { ColourInput, OpenEnum, SearchImageInput } from "./types.js";
@@ -30,7 +31,41 @@ export interface SearchInput {
30
31
  };
31
32
  };
32
33
  mood?: string;
33
- sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
34
+ /**
35
+ * Re-order the final scored results. `relevance` (default) keeps the fused
36
+ * score; `light` / `dark` / `vivid` / `muted` / `colourful` are the colour
37
+ * ramps; `name` (A→Z), `name_desc` (Z→A) and `recent` (newest first) are the
38
+ * catalogue orders — the same three `/search/browse` walks the corpus in, so
39
+ * one word means one order on both lanes.
40
+ */
41
+ sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful" | "name" | "name_desc" | "recent">;
42
+ /**
43
+ * Sort along ONE attribute's declared value ladder — a SEPARATE axis from
44
+ * `sort`, which stays the closed relevance / colour / catalogue vocabulary.
45
+ *
46
+ * `direction` defaults to `"asc"`. Sent together with `sort`, `orderBy` wins
47
+ * and the response `note` says so — a UI holding both controls can send both.
48
+ *
49
+ * Ordering is DECLARED, never inferred from how a value is spelled: an
50
+ * attribute with no stated ladder is a 400 carrying the orderable set in
51
+ * `orderable_attributes`, not a quiet fall back to relevance. A material whose
52
+ * value is off the ladder, or which carries no value at all, sorts LAST in
53
+ * both directions — it is still a real material, it just has no rung.
54
+ */
55
+ orderBy?: {
56
+ attributeId: string;
57
+ direction?: OpenEnum<"asc" | "desc">;
58
+ };
59
+ /**
60
+ * Attribute ids whose resolved value should ride each result under
61
+ * `attributes` — so a page you filtered by `price_tier` can DISPLAY the tier
62
+ * without a second call per material. Up to 8.
63
+ *
64
+ * Three response states, all different: no `attributes` key means you did not
65
+ * ask, a key with `null` means nothing is on record for that material, and
66
+ * anything else is the value. An unknown attribute id is a 400 naming it.
67
+ */
68
+ returnAttributes?: string[];
34
69
  /**
35
70
  * Seed a visual search from an existing variant's image (`variantId`), from a
36
71
  * Material Bank SKU (`materialBankId`), or from a pre-computed image vector
@@ -99,6 +134,11 @@ export interface SearchInput {
99
134
  * `facets.absent`. Absent = no `facets` in the response, no counting cost.
100
135
  */
101
136
  facetCounts?: string[];
137
+ /**
138
+ * Opaque five-minute receipt from a prior pool-derived faceted search.
139
+ * Resubmitting it may only narrow that exact finalized pool.
140
+ */
141
+ refinementToken?: string;
102
142
  /** Only return materials with at least one catalog image. Default false (full catalog). */
103
143
  hasImages?: boolean;
104
144
  /**
@@ -126,10 +166,17 @@ export interface SearchInput {
126
166
  * **ABSENT means the WHOLE GRAPH** — every material MaterialGraph knows,
127
167
  * orderable or not. Absence is never a narrower default, so a caller that
128
168
  * says nothing gets materials it may not be able to buy.
129
- * `"materialBankOrderable"` restricts retrieval to materials carrying a
130
- * Material Bank identity. `"designShopOrderable"` restricts it to what a
131
- * consumer can buy on DesignShop today — about 16,200 materials, with the
132
- * products DesignShop hides from browse excluded.
169
+ * Three scopes gate on CHANNEL membership, which is what "can someone buy
170
+ * this" means: `"materialBankNaOrderable"` (Material Bank North America,
171
+ * ~277,000 materials), `"materialBankEuOrderable"` (Material Bank Europe,
172
+ * ~65,000) and `"designShopOrderable"` (DesignShop, ~34,000, with the
173
+ * products DesignShop hides from browse excluded).
174
+ *
175
+ * `"materialBankOrderable"` is NOT one of them, despite the name. It selects
176
+ * materials carrying a Material Bank identity — a PROVENANCE signal, not a
177
+ * storefront — and admits about 98% of the graph. Use it to exclude the
178
+ * crawl-sourced brands that were never in the Material Bank feed; if you mean
179
+ * orderable, use one of the three above.
133
180
  *
134
181
  * A scope answers what is BUYABLE. It never answers what is SUITABLE: scope
135
182
  * on channel, then refine on sector. A trade-only material can be right for a
@@ -142,7 +189,7 @@ export interface SearchInput {
142
189
  * shortens the page exactly when the query drifts toward the corpus beyond
143
190
  * the Material Bank feed, which is unpredictable from outside.
144
191
  */
145
- corpusScope?: OpenEnum<"all" | "materialBankOrderable" | "designShopOrderable">;
192
+ corpusScope?: CorpusScope;
146
193
  /**
147
194
  * OPS/AUDIT escape hatch — do NOT use for normal search. Default behaviour
148
195
  * (absent or false) excludes sampling-artifact products: multi-swatch sample
@@ -156,7 +203,7 @@ export interface SearchGetParams {
156
203
  colours?: string;
157
204
  logic?: OpenEnum<"all" | "any">;
158
205
  mood?: string;
159
- sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
206
+ sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful" | "name" | "name_desc" | "recent">;
160
207
  tol?: OpenEnum<"tight" | "normal" | "loose">;
161
208
  /** Seed the search from this variant's image. Opaque — pass it through verbatim. */
162
209
  variantId?: VariantId;
@@ -169,4 +216,12 @@ export interface SearchGetParams {
169
216
  hasImages?: OpenEnum<"true">;
170
217
  /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
171
218
  view?: OpenEnum<"full" | "card">;
219
+ /**
220
+ * One JSON OBJECT, e.g. `{"attributeId":"price_tier","direction":"desc"}` —
221
+ * the `orderBy` field of the POST body, JSON-encoded because a query string
222
+ * has no nested values. Same spelling as `filters=` on `/search/browse`.
223
+ */
224
+ orderBy?: string;
225
+ /** Comma-separated attribute ids — the `returnAttributes` list. */
226
+ returnAttributes?: string;
172
227
  }
package/types.d.ts CHANGED
@@ -1,6 +1,35 @@
1
1
  import type { BySpecVerdict } from "./by-spec-types.js";
2
+ /**
3
+ * Hand-mirrored wire types for the MaterialGraph public API (spec 0.5.0).
4
+ *
5
+ * These interfaces intentionally have NO runtime dependency on any
6
+ * `@materialgraph/*` package — they are a standalone, publishable mirror of the
7
+ * shapes in `apps/web/public/openapi.yaml` and the captured fixtures under
8
+ * `docs/surface/fixtures/v0.5.0/`. The `contract-parity` test re-checks them
9
+ * against the real Zod schemas so the mirror cannot silently drift.
10
+ * TOLERANT-PARSING CONTRACT: the public surface declares open response enums —
11
+ * "clients must tolerate unknown enum values and unknown fields". Every union
12
+ * that mirrors a server enum is therefore widened with `(string & {})` so a new
13
+ * server value still type-checks, and objects that may grow fields are read
14
+ * structurally (unknown keys pass through untouched — the client never strips).
15
+ *
16
+ * READING CONTRACT — the three things callers get wrong, stated once here and
17
+ * repeated on the types where each one bites:
18
+ * 1. **Ids are opaque.** Round-trip them verbatim; never parse or construct
19
+ * one. See `./identifiers.ts` for which strings are opaque tokens and which
20
+ * are canonical vocabulary you discover.
21
+ * 2. **Absent is not false.** An optional field that is missing means UNKNOWN,
22
+ * not "known to be absent in reality" — most dangerously for booleans.
23
+ * Values carry `confidence` and `sourceBasis`; report them as claims.
24
+ * 3. **A page is not a corpus.** `results.length` is this page.
25
+ * {@link CountedTotal} — with the `basis` it states — is the claim about the
26
+ * match set. Follow the cursor for the rest, and never report a page size as
27
+ * a total.
28
+ */
29
+ import type { CorpusScopeReceipt } from "./corpus-scope-types.js";
2
30
  import type { Cursor, ProductId, VariantId } from "./identifiers.js";
3
31
  import type { MaterialDetail, MaterialTile } from "./material-types.js";
32
+ import type { QueryUnderstanding } from "./query-understanding-types.js";
4
33
  import type { SearchDisambiguation, SearchSuggestion } from "./search-refinement-types.js";
5
34
  /** A widened string-literal union: known members autocomplete, unknown values still type-check. */
6
35
  export type OpenEnum<T extends string> = T | (string & {});
@@ -226,6 +255,18 @@ export interface SearchResultItem {
226
255
  * shared CandidateVerdict mirror; see {@link MaterialVerdict}.
227
256
  */
228
257
  verdict?: BySpecVerdict;
258
+ /**
259
+ * Resolved values for the attribute ids sent in `returnAttributes`, keyed by
260
+ * attribute id. ABSENT entirely when `returnAttributes` was not sent.
261
+ *
262
+ * Three states, all different, and reading them as two is the mistake this
263
+ * shape exists to prevent: no `attributes` key means you did not ask; a key
264
+ * whose value is `null` means this material carries no accepted value for that
265
+ * attribute; anything else is what the graph holds. Values resolve with the
266
+ * normal variant-over-product precedence, and an array is a multi-valued
267
+ * attribute rather than a list of candidates.
268
+ */
269
+ attributes?: Record<string, string | string[] | null>;
229
270
  }
230
271
  /**
231
272
  * The lean `view:"card"` result item — identity + name + thumbnail + score.
@@ -246,6 +287,18 @@ export interface SearchCardItem {
246
287
  variantName: string | null;
247
288
  imageUrl: string | null;
248
289
  score: number;
290
+ /**
291
+ * Resolved values for the attribute ids sent in `returnAttributes`, keyed by
292
+ * attribute id. ABSENT entirely when `returnAttributes` was not sent.
293
+ *
294
+ * Three states, all different, and reading them as two is the mistake this
295
+ * shape exists to prevent: no `attributes` key means you did not ask; a key
296
+ * whose value is `null` means this material carries no accepted value for that
297
+ * attribute; anything else is what the graph holds. Values resolve with the
298
+ * normal variant-over-product precedence, and an array is a multi-valued
299
+ * attribute rather than a list of candidates.
300
+ */
301
+ attributes?: Record<string, string | string[] | null>;
249
302
  }
250
303
  /**
251
304
  * A feed total with its honesty basis — THE CLAIM ABOUT THE MATCH SET, which is
@@ -326,8 +379,16 @@ export interface SearchFacets {
326
379
  absent: SearchAbsentFacet[];
327
380
  counts: Record<string, SearchFacetCount>;
328
381
  }
382
+ /** Inspectable metadata for an opaque, principal-bound search refinement receipt. */
383
+ export interface SearchRefinementReceipt {
384
+ token: string;
385
+ expiresAt: string;
386
+ poolSize: number;
387
+ schemaVersion: string;
388
+ projectionVersion: number;
389
+ }
329
390
  /** The single candidate source a query resolved to. */
330
- export type SearchMode = OpenEnum<"image" | "colour" | "text" | "lexical">;
391
+ export type SearchMode = OpenEnum<"image" | "colour" | "text" | "lexical" | "entity-browse">;
331
392
  /** Rerank-term report (absent in colour mode — there colour IS the source). */
332
393
  export interface SearchRerankReport {
333
394
  colour?: OpenEnum<"ok" | "failed" | "skipped">;
@@ -373,6 +434,8 @@ export interface SearchResponse {
373
434
  droppedColours?: string[];
374
435
  /** Per-query facet counts — present ONLY when the request sent `facetCounts`. */
375
436
  facets?: SearchFacets;
437
+ /** Receipt that narrows the exact finalized pool behind lower-bound facets. */
438
+ refinement?: SearchRefinementReceipt;
376
439
  note?: string;
377
440
  timings?: Record<string, number>;
378
441
  /** A detected mood term overridden by a stronger colour reading. */
@@ -382,17 +445,15 @@ export interface SearchResponse {
382
445
  * offers the other as a complete executable request.
383
446
  */
384
447
  disambiguation?: SearchDisambiguation;
448
+ /** Structured receipt of the standard query-understanding decisions. */
449
+ understanding?: QueryUnderstanding;
385
450
  /**
386
451
  * The corpus this page AND its `counts` were drawn from — present ONLY when
387
452
  * the request narrowed it. ABSENT means the whole graph: every material
388
453
  * MaterialGraph knows, orderable or not. Read it alongside `counts`: a short
389
454
  * page under a scope is a small SCOPED corpus, not a truncated one.
390
455
  */
391
- corpusScope?: {
392
- scope: OpenEnum<"all" | "materialBankOrderable">;
393
- /** What the scope admits, in one sentence, for a human reader. */
394
- description: string;
395
- };
456
+ corpusScope?: CorpusScopeReceipt;
396
457
  /** Echo of the resolved query input (raw image bytes/vector are never echoed). */
397
458
  query?: Record<string, unknown>;
398
459
  /** A `getVariantBrief` echo of the seed variant, present only in `variantId` seed mode. */