@instruments/materialgraph 0.9.0 → 0.10.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/resolve-client.js CHANGED
@@ -3,7 +3,13 @@ import { resolveOperation } from "./operations.js";
3
3
  * THE QUESTION: "I have words — what do they mean in YOUR vocabulary?" Send up
4
4
  * to 50 phrases as your users typed them; each comes back with every canonical
5
5
  * token MaterialGraph knows it as — brand, exact paint, colour family, category,
6
- * attribute, value — best first, in request order. Backs `mg.resolve()`.
6
+ * attribute, value, and the two code axes (jurisdiction, occupancy group) — best
7
+ * first, in request order. Backs `mg.resolve()`.
8
+ *
9
+ * The code axes read out of a sentence: "a maximum security prison in Kansas"
10
+ * returns `us-ks` and `I-3`, the pair `mg.codeAdvisory()` is keyed on. They are
11
+ * half a context, not a whole one — that call also wants a space type and a
12
+ * sprinklered flag, and validates the combination before it answers.
7
13
  *
8
14
  * A phrase can legitimately mean several things at once ("sage" is two vendor
9
15
  * paints AND a colour family), so each entry carries a LIST of readings rather
@@ -27,13 +33,34 @@ import { resolveOperation } from "./operations.js";
27
33
  * complete a name as someone types, and `mg.glossary()` to EXPLAIN a token you
28
34
  * have already resolved.
29
35
  *
36
+ * - **Every reading says what stands behind it.** `coverage.variants` is how
37
+ * many variants carry it corpus-wide, and `0` is a measured answer rather
38
+ * than missing data — a word this vocabulary knows and the catalogue holds
39
+ * nothing under. That is the case you would otherwise discover only by making
40
+ * a second call, on a hot path, where a second call gets skipped. An ABSENT
41
+ * `coverage` means it could not be determined; never read it as none.
42
+ *
43
+ * Pass `category` when you are grounding these words for a SLOT, and read
44
+ * `coverage.inCategory`: a corpus-wide count is not a promise the reading is
45
+ * reachable inside a given category, and that conflation is what turns a
46
+ * top-ranked suggestion into an unsatisfiable requirement.
47
+ *
30
48
  * @param phrases 1-50 phrases, each 1-200 characters. Sent as-is; each is
31
49
  * resolved independently, so one meaningless phrase never spoils the batch.
50
+ * @param options `category` to scope the coverage answer, and an `AbortSignal`.
32
51
  */
33
- export function resolvePhrasesRequest(transport, phrases, signal) {
52
+ export function resolvePhrasesRequest(transport, phrases, scope) {
53
+ // A bare signal is what this took before `category` existed; keep accepting it
54
+ // so no published call site had to change to gain coverage.
55
+ const options = scope instanceof AbortSignal ? { signal: scope } : (scope ?? {});
34
56
  return transport.requestJson(resolveOperation("resolve_phrases"), {
35
- body: { phrases },
36
- signal,
57
+ body: {
58
+ phrases,
59
+ ...(options.category === undefined
60
+ ? {}
61
+ : { category: options.category }),
62
+ },
63
+ signal: options.signal,
37
64
  });
38
65
  }
39
66
  /**
@@ -27,8 +27,18 @@
27
27
  * pair, for `mg.bySpec()` or `search({ filters })`.
28
28
  * - `attribute_value` — a value-dictionary term, namespaced (`ms_sheen.matte`):
29
29
  * the RIGHT half.
30
+ * - `jurisdiction` — a code jurisdiction id (`us-tx`), for `mg.codeAdvisory()`.
31
+ * - `occupancy_group` — an IBC occupancy group (`R-1`), the other axis the code
32
+ * endpoints are keyed on. BARE, not namespaced, even though the groups live in
33
+ * a value dictionary: `R-1` is the token those endpoints take.
34
+ *
35
+ * The two code axes are half a context, not a whole one. `/codes/advisory` also
36
+ * needs a `spaceType` (or a `roomType` to correlate one from) and a
37
+ * `sprinklered` flag — neither of which a phrase can state — and it validates
38
+ * the combination before it answers, so send them and read its verdict rather
39
+ * than assuming a resolved pair is usable.
30
40
  */
31
- export type ResolutionKind = "brand" | "paint_identity" | "colour" | "category" | "attribute" | "attribute_value";
41
+ export type ResolutionKind = "brand" | "paint_identity" | "colour" | "category" | "attribute" | "attribute_value" | "jurisdiction" | "occupancy_group";
32
42
  /**
33
43
  * How the phrase matched, strongest first. `exact` — it IS the canonical token;
34
44
  * `alias` — it IS a recorded alternative name for it; `lexical` — it resembles
@@ -68,6 +78,49 @@ export interface ResolvedMeaning {
68
78
  ambiguity?: {
69
79
  gap: number;
70
80
  };
81
+ /**
82
+ * How much of the catalogue stands behind this reading — so you never have to
83
+ * make a second, differently-shaped call to find out that a confident answer
84
+ * is empty.
85
+ *
86
+ * ABSENT means coverage could not be determined for this reading; it is a
87
+ * statement about the MEASUREMENT, never about the catalogue. A measured
88
+ * nothing is `{ variants: 0 }`. Do not collapse the two: `attribute` and
89
+ * `attribute_value` readings (and `brand`, whose corpus weight comes free)
90
+ * carry it; the rest have no equivalently cheap count behind them and report
91
+ * nothing rather than a figure measured a different way.
92
+ */
93
+ coverage?: MeaningCoverage;
94
+ }
95
+ /** How much of the catalogue stands behind one reading. */
96
+ export interface MeaningCoverage {
97
+ /**
98
+ * How many variants carry this reading, CORPUS-WIDE and across every brand —
99
+ * never scoped to the `category` you sent, even when you sent one. `0` is a
100
+ * measured answer, not missing data: MaterialGraph knows the word and the
101
+ * catalogue holds nothing under it, which is the case worth catching before
102
+ * you spend the token on a filter.
103
+ *
104
+ * For an `attribute` reading over an ARRAY-valued attribute a variant is
105
+ * counted once per token it carries, so the figure is an upper bound on
106
+ * distinct variants there. It is exact for scalar attributes, exact for every
107
+ * `attribute_value`, and the zero is exact in every case.
108
+ */
109
+ variants: number;
110
+ /**
111
+ * Whether the `category` you sent holds ANY accepted value for the attribute
112
+ * behind this reading. Present only when you sent a category AND the answer
113
+ * could be determined — its absence is a statement about the measurement, not
114
+ * a `false`.
115
+ *
116
+ * `false` means no criterion built on this reading can be satisfied inside
117
+ * that category at any rung of retrieval's relaxation ladder, however large
118
+ * `variants` is. Read this rather than inferring reachability from the count:
119
+ * an attribute with forty thousand values across the catalogue can hold none
120
+ * at all in Wall Finishes. The probe is ATTRIBUTE-level, so `true` says the
121
+ * category holds SOME value for the attribute, not that it holds this one.
122
+ */
123
+ inCategory?: boolean;
71
124
  }
72
125
  /** What one phrase resolved to — including, explicitly, nothing. */
73
126
  export interface PhraseResolution {
@@ -95,6 +148,22 @@ export interface PhraseResolution {
95
148
  */
96
149
  unresolved: boolean;
97
150
  }
151
+ /** Optional scoping for a resolve call. */
152
+ export interface ResolvePhrasesOptions {
153
+ /**
154
+ * The product category you are grounding these words FOR — a slot, not a
155
+ * filter. It adds {@link MeaningCoverage.inCategory} to every `attribute` and
156
+ * `attribute_value` reading. Omit for corpus-wide only.
157
+ */
158
+ category?: string;
159
+ signal?: AbortSignal;
160
+ }
161
+ /**
162
+ * What `mg.resolve()` accepts as its second argument. A bare `AbortSignal` is
163
+ * still accepted, exactly as before {@link ResolvePhrasesOptions} existed, so no
164
+ * existing call site had to move.
165
+ */
166
+ export type ResolvePhrasesScope = AbortSignal | ResolvePhrasesOptions;
98
167
  /** The resolve response: one entry per phrase, in the order you sent them. */
99
168
  export interface ResolveResponse {
100
169
  /** One per phrase, in request order — never reordered, never deduplicated. */
package/run-events.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RunEvent } from "./run-types.js";
1
2
  /**
2
3
  * Typed payloads for the composition Run event family — the friction fix flagged
3
4
  * by the MG-443 walk. These narrow the loose {@link RunEvent.payloadJson} so
@@ -13,7 +14,6 @@
13
14
  * as standalone TS types (no cross-package import; the SDK is publishable).
14
15
  */
15
16
  import type { OpenEnum } from "./types.js";
16
- import type { RunEvent } from "./run-types.js";
17
17
  export declare const COMPOSITION_PHASE_EVENT_TYPES: readonly ["composition.phase", "material-box.phase"];
18
18
  export declare const COMPOSITION_PROGRAMME_EVENT_TYPES: readonly ["composition.programme", "material-box.brief"];
19
19
  export declare const COMPOSITION_REVIEW_CRITERIA_EVENT_TYPES: readonly ["composition.review-criteria", "material-box.review-criteria"];
package/run-events.js CHANGED
@@ -1,5 +1,8 @@
1
1
  // ── Wire event-type strings (both generations) ───────────────────────────────
2
- export const COMPOSITION_PHASE_EVENT_TYPES = ["composition.phase", "material-box.phase"];
2
+ export const COMPOSITION_PHASE_EVENT_TYPES = [
3
+ "composition.phase",
4
+ "material-box.phase",
5
+ ];
3
6
  export const COMPOSITION_PROGRAMME_EVENT_TYPES = [
4
7
  "composition.programme",
5
8
  "material-box.brief",
package/runs.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HttpTransport } from "./http-transport.js";
2
- import type { FinishScheduleResponse, GetRunScheduleOptions } from "./schedule-types.js";
3
2
  import { type CapabilityManifestResponse, type CreatePublicRunRequest, type CreatePublicRunResponse, type DrainRunEventsOptions, type ListRunsOptions, type PublicRunListResponse, type PublicRunResponse, type ResumeRunOptions, type RunEvent, type SourceMaterialBatchDetail, type SourceMaterialBatchReceipt, type StreamRunEventsOptions, type UploadSourceMaterialInput } from "./run-types.js";
3
+ import type { FinishScheduleResponse, GetRunScheduleOptions } from "./schedule-types.js";
4
4
  /**
5
5
  * Upload one atomic source-material batch (`POST /api/v1/source-material`).
6
6
  * Accepts the pile-of-files shape (`{ files }` → repeatable `files` field) or
package/runs.js CHANGED
@@ -7,10 +7,10 @@
7
7
  */
8
8
  import { MaterialGraphError } from "./errors.js";
9
9
  import { HttpTransport } from "./http-transport.js";
10
- import { findSdkOperation, operationPath } from "./operations.js";
10
+ import { findSdkOperation, operationPath, } from "./operations.js";
11
+ import { TERMINAL_RUN_EVENT_TYPES, } from "./run-types.js";
11
12
  import { checkSourceMaterialBatchDetail } from "./source-material-validation.js";
12
13
  import { readSseFrames } from "./sse.js";
13
- import { TERMINAL_RUN_EVENT_TYPES, } from "./run-types.js";
14
14
  function runOperation(operationId) {
15
15
  const match = findSdkOperation(operationId);
16
16
  if (!match)
@@ -224,7 +224,9 @@ function decodeRunEvent(data) {
224
224
  export async function* streamRunEvents(transport, runId, options = {}) {
225
225
  const body = await transport.openStream(runOperation("list_public_run_events"), {
226
226
  pathParams: { runId },
227
- ...(options.after === undefined ? {} : { query: { after: options.after } }),
227
+ ...(options.after === undefined
228
+ ? {}
229
+ : { query: { after: options.after } }),
228
230
  ...(options.signal ? { signal: options.signal } : {}),
229
231
  accept: "text/event-stream",
230
232
  subject: "run-event stream",
@@ -265,7 +267,9 @@ export async function drainRunEvents(transport, runId, options) {
265
267
  }
266
268
  }
267
269
  catch (error) {
268
- const causedByDeadline = controller.signal.aborted && error instanceof MaterialGraphError && error.kind === "aborted";
270
+ const causedByDeadline = controller.signal.aborted &&
271
+ error instanceof MaterialGraphError &&
272
+ error.kind === "aborted";
269
273
  if (!causedByDeadline)
270
274
  throw error;
271
275
  }
@@ -1,9 +1,10 @@
1
1
  /** Types for structured refinements returned by MaterialGraph search. */
2
2
  import type { SearchInput } from "./types.js";
3
3
  /**
4
- * A hard canonical-attribute filter accepted by the search engine — either a
5
- * term match (`{ attributeId, term }`) or a presence check
6
- * (`{ attributeId, hasValue: true }`). Applied as a restriction BEFORE ranking.
4
+ * A hard canonical-attribute filter accepted by the search engine — a term match
5
+ * (`{ attributeId, term }`), a presence check (`{ attributeId, hasValue: true }`)
6
+ * or a NUMERIC RANGE (`{ attributeId, min, max }`, bounds inclusive, at least one
7
+ * stated). Applied as a restriction BEFORE ranking.
7
8
  *
8
9
  * The `attributeId` may be a real registry id (`"fire_classifications"`,
9
10
  * `"environmental_product_declaration"`) OR the virtual
@@ -52,6 +53,13 @@ export type SearchFilter = {
52
53
  attributeId: string;
53
54
  hasValue: true;
54
55
  trust?: SearchFilterTrust;
56
+ } | {
57
+ attributeId: string;
58
+ /** Inclusive lower bound. Omit for a ceiling-only range. */
59
+ min?: number;
60
+ /** Inclusive upper bound. Omit for a floor-only range. */
61
+ max?: number;
62
+ trust?: SearchFilterTrust;
55
63
  };
56
64
  /** One structured refinement returned with a search response. */
57
65
  export interface SearchSuggestion {
@@ -12,17 +12,33 @@ export const VERDICT_STATES = [
12
12
  /** `attributeVerdictSchema.strength`. */
13
13
  export const VERDICT_STRENGTHS = ["required", "preferred"];
14
14
  /** `verdictBaselineSchema` discriminant — the four arms of what a cell was judged against. */
15
- export const VERDICT_BASELINE_KINDS = ["term", "presence", "seed", "image"];
15
+ export const VERDICT_BASELINE_KINDS = [
16
+ "term",
17
+ "presence",
18
+ "seed",
19
+ "image",
20
+ ];
16
21
  /** The `image` baseline arm's own `source` enum. */
17
22
  export const VERDICT_IMAGE_SOURCES = ["variant", "vector"];
18
23
  /** `verdictDeviationSchema.direction`. */
19
- export const VERDICT_DEVIATION_DIRECTIONS = ["above", "below", "other"];
24
+ export const VERDICT_DEVIATION_DIRECTIONS = [
25
+ "above",
26
+ "below",
27
+ "other",
28
+ ];
20
29
  /** `verdictTrustGapSchema` — why a real value did not clear the trust bar. */
21
- export const VERDICT_TRUST_GAPS = ["below_confidence", "basis_unmet", "proposed"];
30
+ export const VERDICT_TRUST_GAPS = [
31
+ "below_confidence",
32
+ "basis_unmet",
33
+ "proposed",
34
+ ];
22
35
  /** `verdictGradeSchema` — the rolled-up card grade. */
23
36
  export const VERDICT_GRADES = ["meets_all", "close", "hard_no"];
24
37
  /** `visualMeaningSchema.basis` — where a meaning came from. */
25
- export const VISUAL_MEANING_BASES = ["canonical_attribute", "image_colour_analysis"];
38
+ export const VISUAL_MEANING_BASES = [
39
+ "canonical_attribute",
40
+ "image_colour_analysis",
41
+ ];
26
42
  // ── Leaf checks ──────────────────────────────────────────────────────────────
27
43
  /** `palette[]` — `{ hex, proportion, name: string.nullable().optional() }`. */
28
44
  function checkPaletteSwatch(value, what, fail) {
@@ -165,6 +181,7 @@ export function checkSearchResultItem(value, what, fail) {
165
181
  checkSignals(record.signals, `${what}.signals`, fail);
166
182
  requireOptionalBoolean(record.retired, `${what}.retired`, fail);
167
183
  requireOptionalBoolean(record.belowFloor, `${what}.belowFloor`, fail);
184
+ requireOptionalBoolean(record.admittedOnColour, `${what}.admittedOnColour`, fail);
168
185
  if (record.siblingCount !== undefined) {
169
186
  requireIntegerAtLeast(record.siblingCount, `${what}.siblingCount`, fail, 1);
170
187
  }
@@ -91,7 +91,9 @@ function checkFile(value, what, fail) {
91
91
  // fields is a canonical change to a schema shared with the upload path, and
92
92
  // belongs in its own ticket with this mirror following after.
93
93
  bytes: requireSafeInteger(record.bytes, `${what}.bytes`, fail),
94
- pages: record.pages === null ? null : requireSafeInteger(record.pages, `${what}.pages`, fail),
94
+ pages: record.pages === null
95
+ ? null
96
+ : requireSafeInteger(record.pages, `${what}.pages`, fail),
95
97
  url: requireString(record.url, `${what}.url`, fail),
96
98
  analyses: analyses.map((analysis, index) => checkAnalysis(analysis, `${what}.analyses[${index}]`, fail)),
97
99
  };
@@ -0,0 +1,53 @@
1
+ export interface SpecFacetValue {
2
+ value: string;
3
+ variants: number;
4
+ brands: number;
5
+ label?: string;
6
+ }
7
+ /**
8
+ * A NUMERIC facet's observed span and the coverage behind it.
9
+ *
10
+ * A range, not buckets — no bucketing scheme has been decided, and one invented
11
+ * on the wire would be treated as canonical by everyone who read it. Both bounds
12
+ * are INCLUSIVE, matching `specRange`.
13
+ *
14
+ * READ `variants` BEFORE TRUSTING A FILTER. It is how many variants carry a
15
+ * value that parses as a number, i.e. how much of the corpus a range can reach;
16
+ * a facet that exists says the attribute is queryable, not that it is populated.
17
+ * `unparseableVariants` is the honesty companion: values recorded here that are
18
+ * not numbers, which a range EXCLUDES rather than reading as zero.
19
+ */
20
+ export interface SpecFacetRange {
21
+ min: number;
22
+ max: number;
23
+ variants: number;
24
+ unparseableVariants: number;
25
+ }
26
+ export interface SpecFacet {
27
+ attributeId: string;
28
+ name: string;
29
+ isArray: boolean;
30
+ /**
31
+ * How this facet is queried. `token` — pick a value from `values` and send it
32
+ * as `attribute:value`. `numeric` — `values` is EMPTY and `range` carries the
33
+ * span; send inclusive bounds via `specRangeCriterion` instead.
34
+ */
35
+ kind: "token" | "numeric" | (string & {});
36
+ brands: number;
37
+ /** EMPTY for a `numeric` facet, which has no token vocabulary. */
38
+ values: SpecFacetValue[];
39
+ /** Present ONLY on a `numeric` facet. */
40
+ range?: SpecFacetRange;
41
+ description?: string;
42
+ }
43
+ export interface SpecFacetsListResponse {
44
+ mode: "facets";
45
+ count: number;
46
+ facets: SpecFacet[];
47
+ }
48
+ export interface SpecFacetsDistributionResponse {
49
+ mode: "distribution";
50
+ attributeId: string;
51
+ count: number;
52
+ values: SpecFacetValue[];
53
+ }
@@ -0,0 +1,7 @@
1
+ // ── Specification facets + attribute values ─────────────────────────────────
2
+ //
3
+ // Hand-maintained mirror of the `SpecificationFacet` OpenAPI schema (the source
4
+ // of truth on the wire); the `sdk-openapi-parity` test gates this against the
5
+ // generated spec. Split out of `types.ts` to keep that file under its growth
6
+ // baseline; re-exported from `types.ts` so importers see no change.
7
+ export {};
@@ -57,6 +57,32 @@ export declare function certificationProgramme(programme: string): Certification
57
57
  * client.bySpec([specCriterion("primary_material", "textile"), specCriterion("fire_classifications", "astm_e84_class_a")]);
58
58
  */
59
59
  export declare function specCriterion(attributeId: string, value: string): string;
60
+ /**
61
+ * An `attribute:min..max` RANGE criterion string for `bySpec`, for a
62
+ * NUMBER-valued spec (`light_reflectance_value`, `nrc_rating`,
63
+ * `slip_resistance_dcof`, …). Pass the result in `bySpec`'s `ranges` option.
64
+ *
65
+ * BOTH BOUNDS ARE INCLUSIVE — `{ min: 60 }` matches a value of exactly 60 —
66
+ * because spec language is written that way ("LRV of 60 or more", "DCOF ≥ 0.42")
67
+ * and because inclusive-both-ends means adjacent ranges overlap at the join
68
+ * rather than leaving a gap that hides products.
69
+ *
70
+ * At least one bound is REQUIRED: a range with neither is a presence check, and
71
+ * this throws rather than sending a criterion the server would have to refuse.
72
+ * Discover which attributes take a range — and the span they actually occupy —
73
+ * from `specificationFacets()`: those are the facets with `kind: "numeric"`.
74
+ *
75
+ * @example
76
+ * // light paints, every brand at once
77
+ * client.bySpec([], { ranges: [specRangeCriterion("light_reflectance_value", { min: 60 })] });
78
+ * @example
79
+ * // a mid-tone band
80
+ * specRangeCriterion("light_reflectance_value", { min: 30, max: 45 }); // "light_reflectance_value:30..45"
81
+ */
82
+ export declare function specRangeCriterion(attributeId: string, bounds: {
83
+ min?: number;
84
+ max?: number;
85
+ }): string;
60
86
  /**
61
87
  * An `attribute:value` criterion string for `bySpec` that filters by
62
88
  * certification PROGRAMME across all four certification buckets.
package/specifications.js CHANGED
@@ -65,6 +65,38 @@ export function certificationProgramme(programme) {
65
65
  export function specCriterion(attributeId, value) {
66
66
  return `${attributeId}:${value}`;
67
67
  }
68
+ /**
69
+ * An `attribute:min..max` RANGE criterion string for `bySpec`, for a
70
+ * NUMBER-valued spec (`light_reflectance_value`, `nrc_rating`,
71
+ * `slip_resistance_dcof`, …). Pass the result in `bySpec`'s `ranges` option.
72
+ *
73
+ * BOTH BOUNDS ARE INCLUSIVE — `{ min: 60 }` matches a value of exactly 60 —
74
+ * because spec language is written that way ("LRV of 60 or more", "DCOF ≥ 0.42")
75
+ * and because inclusive-both-ends means adjacent ranges overlap at the join
76
+ * rather than leaving a gap that hides products.
77
+ *
78
+ * At least one bound is REQUIRED: a range with neither is a presence check, and
79
+ * this throws rather than sending a criterion the server would have to refuse.
80
+ * Discover which attributes take a range — and the span they actually occupy —
81
+ * from `specificationFacets()`: those are the facets with `kind: "numeric"`.
82
+ *
83
+ * @example
84
+ * // light paints, every brand at once
85
+ * client.bySpec([], { ranges: [specRangeCriterion("light_reflectance_value", { min: 60 })] });
86
+ * @example
87
+ * // a mid-tone band
88
+ * specRangeCriterion("light_reflectance_value", { min: 30, max: 45 }); // "light_reflectance_value:30..45"
89
+ */
90
+ export function specRangeCriterion(attributeId, bounds) {
91
+ const { min, max } = bounds;
92
+ if (min === undefined && max === undefined) {
93
+ throw new Error(`specRangeCriterion("${attributeId}") needs a min, a max, or both — a range with neither would match every recorded value. Use hasSpec() for a presence check.`);
94
+ }
95
+ if (min !== undefined && max !== undefined && min > max) {
96
+ throw new Error(`specRangeCriterion("${attributeId}") is inverted (min ${min} > max ${max}) and can only ever match nothing.`);
97
+ }
98
+ return `${attributeId}:${min ?? ""}..${max ?? ""}`;
99
+ }
68
100
  /**
69
101
  * An `attribute:value` criterion string for `bySpec` that filters by
70
102
  * certification PROGRAMME across all four certification buckets.
package/sse.js CHANGED
@@ -40,7 +40,9 @@ export class SseDecoder {
40
40
  flush() {
41
41
  if (this.#buffer.length > 0) {
42
42
  // At end of stream a deferred trailing CR is a real terminator, not data.
43
- const line = this.#buffer.endsWith("\r") ? this.#buffer.slice(0, -1) : this.#buffer;
43
+ const line = this.#buffer.endsWith("\r")
44
+ ? this.#buffer.slice(0, -1)
45
+ : this.#buffer;
44
46
  this.#buffer = "";
45
47
  const frame = this.#consumeLine(line);
46
48
  if (frame)
package/suggest-types.js CHANGED
@@ -21,6 +21,8 @@
21
21
  * Undefined members are dropped by the transport.
22
22
  */
23
23
  export function suggestQuery(q, options) {
24
- const kind = Array.isArray(options?.kind) ? options.kind.join(",") : options?.kind;
24
+ const kind = Array.isArray(options?.kind)
25
+ ? options.kind.join(",")
26
+ : options?.kind;
25
27
  return { q, limit: options?.limit, kind };
26
28
  }
package/types.d.ts CHANGED
@@ -121,10 +121,14 @@ export interface SearchResultItem {
121
121
  brandSlug: string | null;
122
122
  productId: ProductId | null;
123
123
  /**
124
- * The variant's Material Bank SKU — the same `materialBankId` the material
125
- * record carries, so a match card can render straight from a result instead of
126
- * paying a per-result lookup. Populated on the MATCH lanes (`match()` and
127
- * `matchStream()` frames).
124
+ * The variant's Material Bank SKU — the ONE EXTERNAL identifier on a result,
125
+ * in deliberate contrast to the opaque `variantId` above. `variantId` is ours
126
+ * and means nothing anywhere else; this is the id you are expected to match
127
+ * against Material Bank's own catalogue, so a result list can be joined to a
128
+ * cart, an order or an analytics event without a per-result lookup.
129
+ *
130
+ * Populated on EVERY lane: `search()` (all four arms), `match()`, and
131
+ * `matchStream()` frames.
128
132
  *
129
133
  * ABSENT means no MB identity is known for the variant — never that the SKU
130
134
  * cannot be ordered. Presence is the orderability tell; absence is silence.
@@ -189,6 +193,17 @@ export interface SearchResultItem {
189
193
  * dropped by default. Appended after the kept results.
190
194
  */
191
195
  belowFloor?: boolean;
196
+ /**
197
+ * Present (true) only on Match rows the colour probe admitted (match only):
198
+ * this material is as close in colour as the catalogue gets, and its image
199
+ * similarity is BELOW the match quality floor by design. It is on the page
200
+ * because nothing image-retrieved is nearer in colour, not because it cleared
201
+ * the floor — present it as a colour answer or suppress it knowingly. Absent
202
+ * (never `false`) on rows that came through image retrieval. Distinct from
203
+ * {@link SearchResultItem.belowFloor}, which marks rejects returned only under
204
+ * `includeBelowFloor`.
205
+ */
206
+ admittedOnColour?: boolean;
192
207
  siblingCount?: number;
193
208
  visualMeanings?: VisualMeaning[];
194
209
  /**
@@ -567,12 +582,24 @@ export interface SearchInput {
567
582
  mood?: string;
568
583
  sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
569
584
  /**
570
- * Seed a visual search from an existing variant's image (`variantId`) or a
571
- * pre-computed image vector (`vector`). Only the `{ variantId }` form carries a
572
- * seed variant, so it is the only form `verdicts` can diff a candidate against.
585
+ * Seed a visual search from an existing variant's image (`variantId`), from a
586
+ * Material Bank SKU (`materialBankId`), or from a pre-computed image vector
587
+ * (`vector`).
588
+ *
589
+ * `materialBankId` exists so a caller already holding a SKU does not have to
590
+ * ask us to translate it before it can ask us anything useful — one call, not
591
+ * two. It resolves to the variant server-side and then behaves identically to
592
+ * the `{ variantId }` form, seed verdicts included. A SKU that names no
593
+ * variant is a 400 carrying the offending value: never an empty result set,
594
+ * never a silent fall back to unseeded search.
595
+ *
596
+ * The `{ vector }` form carries no seed variant, so it is the only one
597
+ * `verdicts` cannot diff a candidate against.
573
598
  */
574
599
  similarTo?: {
575
600
  variantId: VariantId;
601
+ } | {
602
+ materialBankId: string;
576
603
  } | {
577
604
  vector: string;
578
605
  };
@@ -691,6 +718,13 @@ export interface ResolveAttributeOptions {
691
718
  /** Options for `search/specifications`. */
692
719
  export interface BySpecOptions {
693
720
  limit?: number;
721
+ /**
722
+ * Numeric range criteria as `attribute:min..max` strings, ANDed with the
723
+ * `spec` criteria. Build them with `specRangeCriterion` rather than by hand —
724
+ * both bounds are inclusive and at least one is required, and a hand-built
725
+ * token with neither is refused by the server rather than matching everything.
726
+ */
727
+ ranges?: string[];
694
728
  }
695
729
  export type * from "./demand-types.js";
696
730
  /** One quarter's tally. Sparse across a series — an absent quarter is not a zero. */
@@ -731,31 +765,7 @@ export interface ExtractedPalette {
731
765
  export interface ExtractPaletteResponse {
732
766
  palette: ExtractedPalette | null;
733
767
  }
734
- export interface SpecFacetValue {
735
- value: string;
736
- variants: number;
737
- brands: number;
738
- label?: string;
739
- }
740
- export interface SpecFacet {
741
- attributeId: string;
742
- name: string;
743
- isArray: boolean;
744
- brands: number;
745
- values: SpecFacetValue[];
746
- description?: string;
747
- }
748
- export interface SpecFacetsListResponse {
749
- mode: "facets";
750
- count: number;
751
- facets: SpecFacet[];
752
- }
753
- export interface SpecFacetsDistributionResponse {
754
- mode: "distribution";
755
- attributeId: string;
756
- count: number;
757
- values: SpecFacetValue[];
758
- }
768
+ export type * from "./spec-facet-types.js";
759
769
  export interface ApplicableSpecAttribute {
760
770
  id: string;
761
771
  name: string;