@dejadex/web 0.0.0-stage → 0.1.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +214 -2
  3. package/dist/errors.d.ts +8 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/geometry/homography.d.ts +75 -0
  8. package/dist/geometry/homography.d.ts.map +1 -0
  9. package/dist/geometry/homography.js +146 -0
  10. package/dist/geometry/homography.js.map +1 -0
  11. package/dist/identify/assets.d.ts +39 -0
  12. package/dist/identify/assets.d.ts.map +1 -0
  13. package/dist/identify/assets.js +167 -0
  14. package/dist/identify/assets.js.map +1 -0
  15. package/dist/identify/decode.d.ts +6 -0
  16. package/dist/identify/decode.d.ts.map +1 -0
  17. package/dist/identify/decode.js +53 -0
  18. package/dist/identify/decode.js.map +1 -0
  19. package/dist/identify/engine.d.ts +32 -0
  20. package/dist/identify/engine.d.ts.map +1 -0
  21. package/dist/identify/engine.js +127 -0
  22. package/dist/identify/engine.js.map +1 -0
  23. package/dist/identify/identifier.d.ts +81 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +327 -0
  26. package/dist/identify/identifier.js.map +1 -0
  27. package/dist/identify/manifest.d.ts +60 -0
  28. package/dist/identify/manifest.d.ts.map +1 -0
  29. package/dist/identify/manifest.js +211 -0
  30. package/dist/identify/manifest.js.map +1 -0
  31. package/dist/identify/preprocess.d.ts +52 -0
  32. package/dist/identify/preprocess.d.ts.map +1 -0
  33. package/dist/identify/preprocess.js +195 -0
  34. package/dist/identify/preprocess.js.map +1 -0
  35. package/dist/identify/router.d.ts +60 -0
  36. package/dist/identify/router.d.ts.map +1 -0
  37. package/dist/identify/router.js +83 -0
  38. package/dist/identify/router.js.map +1 -0
  39. package/dist/identify/runtime.d.ts +69 -0
  40. package/dist/identify/runtime.d.ts.map +1 -0
  41. package/dist/identify/runtime.js +140 -0
  42. package/dist/identify/runtime.js.map +1 -0
  43. package/dist/identify/search.d.ts +35 -0
  44. package/dist/identify/search.d.ts.map +1 -0
  45. package/dist/identify/search.js +99 -0
  46. package/dist/identify/search.js.map +1 -0
  47. package/dist/identify/server.d.ts +60 -0
  48. package/dist/identify/server.d.ts.map +1 -0
  49. package/dist/identify/server.js +149 -0
  50. package/dist/identify/server.js.map +1 -0
  51. package/dist/identify/shard.d.ts +31 -0
  52. package/dist/identify/shard.d.ts.map +1 -0
  53. package/dist/identify/shard.js +146 -0
  54. package/dist/identify/shard.js.map +1 -0
  55. package/dist/identify/types.d.ts +107 -0
  56. package/dist/identify/types.d.ts.map +1 -0
  57. package/dist/identify/types.js +7 -0
  58. package/dist/identify/types.js.map +1 -0
  59. package/dist/identify/worker.d.ts +2 -0
  60. package/dist/identify/worker.d.ts.map +1 -0
  61. package/dist/identify/worker.js +13 -0
  62. package/dist/identify/worker.js.map +1 -0
  63. package/dist/identify/workerProtocol.d.ts +54 -0
  64. package/dist/identify/workerProtocol.d.ts.map +1 -0
  65. package/dist/identify/workerProtocol.js +113 -0
  66. package/dist/identify/workerProtocol.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +16 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lease/verify.d.ts +69 -0
  72. package/dist/lease/verify.d.ts.map +1 -0
  73. package/dist/lease/verify.js +235 -0
  74. package/dist/lease/verify.js.map +1 -0
  75. package/dist/scanner.d.ts +10 -0
  76. package/dist/scanner.d.ts.map +1 -0
  77. package/dist/scanner.js +58 -0
  78. package/dist/scanner.js.map +1 -0
  79. package/dist/types.d.ts +180 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/usage/meter.d.ts +83 -0
  84. package/dist/usage/meter.d.ts.map +1 -0
  85. package/dist/usage/meter.js +111 -0
  86. package/dist/usage/meter.js.map +1 -0
  87. package/dist/usage/store.d.ts +52 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +198 -0
  90. package/dist/usage/store.js.map +1 -0
  91. package/package.json +64 -4
  92. package/src/errors.ts +24 -0
  93. package/src/geometry/homography.ts +178 -0
  94. package/src/identify/assets.ts +189 -0
  95. package/src/identify/decode.ts +54 -0
  96. package/src/identify/engine.ts +167 -0
  97. package/src/identify/identifier.ts +419 -0
  98. package/src/identify/manifest.ts +240 -0
  99. package/src/identify/preprocess.ts +227 -0
  100. package/src/identify/router.ts +115 -0
  101. package/src/identify/runtime.ts +197 -0
  102. package/src/identify/search.ts +115 -0
  103. package/src/identify/server.ts +167 -0
  104. package/src/identify/shard.ts +154 -0
  105. package/src/identify/types.ts +122 -0
  106. package/src/identify/worker.ts +13 -0
  107. package/src/identify/workerProtocol.ts +136 -0
  108. package/src/index.ts +71 -0
  109. package/src/lease/verify.ts +252 -0
  110. package/src/scanner.ts +56 -0
  111. package/src/types.ts +196 -0
  112. package/src/usage/meter.ts +165 -0
  113. package/src/usage/store.ts +245 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The client of `POST /v1/identify`: crops out, the same answer shape the
3
+ * device produces back. One call is one request, bounded by one deadline that
4
+ * covers the headers and the body; the answer is validated field by field,
5
+ * never trusted.
6
+ */
7
+ import { anySignal } from './assets.js'
8
+ import { MAX_TOP, type IdentifyCandidate, type IdentifyKind, type IdentifyPurpose, type IdentifyResult } from './types.js'
9
+
10
+ export type ServerFetch = (input: string, init: { method: 'POST'; body: FormData; headers: Record<string, string>; signal: AbortSignal }) => Promise<Response>
11
+
12
+ export interface ServerRequest {
13
+ readonly endpoint: string
14
+ readonly crops: readonly Blob[]
15
+ /** The lease, as bearer. */
16
+ readonly token: string
17
+ readonly top: number
18
+ readonly session?: string
19
+ readonly purpose?: IdentifyPurpose
20
+ readonly timeoutMs: number
21
+ readonly signal?: AbortSignal
22
+ readonly fetch: ServerFetch
23
+ }
24
+
25
+ export type ServerFailureKind =
26
+ /** The deadline passed. */
27
+ | 'timeout'
28
+ /** The caller aborted. */
29
+ | 'aborted'
30
+ /** No answer at all: offline, DNS, CORS, connection reset. */
31
+ | 'network'
32
+ /** 401 / 403 / 409: this lease (its origin, domains, tenant or model version) is refused. */
33
+ | 'refused'
34
+ /** 429, with `retryAfterMs`. */
35
+ | 'rate_limited'
36
+ /** 5xx. */
37
+ | 'server_error'
38
+ /** Another 4xx: something about this request (e.g. a crop that is not a JPEG). */
39
+ | 'bad_request'
40
+ /** A 2xx whose body is not the contract. */
41
+ | 'malformed'
42
+
43
+ export type ServerAnswer =
44
+ | { readonly ok: true; readonly modelVersion: string; readonly results: readonly IdentifyResult[] }
45
+ | { readonly ok: false; readonly kind: ServerFailureKind; readonly status?: number; readonly retryAfterMs?: number; readonly detail: string }
46
+
47
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message || err.name : String(err))
48
+
49
+ /** `Retry-After` as delay-seconds or an HTTP date; null when absent or unreadable. */
50
+ export function parseRetryAfter(value: string | null, now: number): number | null {
51
+ if (value === null) return null
52
+ const v = value.trim()
53
+ if (/^\d+$/.test(v)) return Number(v) * 1000
54
+ const at = Date.parse(v)
55
+ return Number.isNaN(at) ? null : Math.max(0, at - now)
56
+ }
57
+
58
+ export async function postIdentify(req: ServerRequest): Promise<ServerAnswer> {
59
+ if (req.signal?.aborted) return { ok: false, kind: 'aborted', detail: 'aborted' }
60
+ const deadline = AbortSignal.timeout(req.timeoutMs)
61
+ const signal = anySignal([deadline, req.signal])
62
+
63
+ const body = new FormData()
64
+ req.crops.forEach((crop, i) => body.append(`crop${i}`, crop, `crop${i}.jpg`))
65
+ body.append('top', String(req.top))
66
+ if (req.session !== undefined) body.append('session', req.session)
67
+ if (req.purpose !== undefined) body.append('purpose', req.purpose)
68
+
69
+ // Which signal fired decides the outcome, not the error's name: runtimes
70
+ // disagree on what they throw for each.
71
+ const failed = (kind: ServerFailureKind, detail: string, extra: { status?: number; retryAfterMs?: number } = {}): ServerAnswer => {
72
+ if (req.signal?.aborted) return { ok: false, kind: 'aborted', detail: 'aborted' }
73
+ if (deadline.aborted) return { ok: false, kind: 'timeout', detail: `no answer within ${req.timeoutMs} ms` }
74
+ return { ok: false, kind, detail, ...extra }
75
+ }
76
+
77
+ let res: Response
78
+ try {
79
+ res = await req.fetch(req.endpoint, { method: 'POST', body, headers: { Authorization: `Bearer ${req.token}` }, signal })
80
+ } catch (err) {
81
+ return failed('network', errorText(err))
82
+ }
83
+ let data: unknown
84
+ try {
85
+ // Inside the same deadline as the request: a slow body is a late answer.
86
+ const type = res.headers.get('content-type') ?? ''
87
+ if (/[/+]json\b/i.test(type)) data = await res.json()
88
+ else await res.text() // drained, so a slow body still counts against the deadline
89
+ } catch (err) {
90
+ return failed(res.ok ? 'malformed' : 'server_error', `unreadable answer: ${errorText(err)}`, { status: res.status })
91
+ }
92
+ if (!res.ok) {
93
+ const detail = problemText(res.status, data)
94
+ if (res.status === 401 || res.status === 403 || res.status === 409) return failed('refused', detail, { status: res.status })
95
+ if (res.status === 429) {
96
+ const retryAfterMs = parseRetryAfter(res.headers.get('retry-after'), Date.now()) ?? 10_000
97
+ return failed('rate_limited', detail, { status: res.status, retryAfterMs })
98
+ }
99
+ if (res.status >= 500) return failed('server_error', detail, { status: res.status })
100
+ return failed('bad_request', detail, { status: res.status })
101
+ }
102
+ const parsed = parseAnswer(data, req.crops.length, req.top)
103
+ if (typeof parsed === 'string') return failed('malformed', `malformed answer: ${parsed}`, { status: res.status })
104
+ return { ok: true, ...parsed }
105
+ }
106
+
107
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
108
+
109
+ function problemText(status: number, data: unknown): string {
110
+ if (isRecord(data)) {
111
+ const code = typeof data.code === 'string' ? data.code : `HTTP_${status}`
112
+ const detail = typeof data.detail === 'string' ? data.detail : typeof data.title === 'string' ? data.title : ''
113
+ return detail ? `${code}: ${detail}` : code
114
+ }
115
+ return `HTTP_${status}`
116
+ }
117
+
118
+ /**
119
+ * The 200 body, checked field by field. Returns the results in index order,
120
+ * or a string naming the first thing wrong. A crop with no result is allowed:
121
+ * it stays unidentified, as on any failure.
122
+ */
123
+ export function parseAnswer(data: unknown, cropCount: number, top: number): { modelVersion: string; results: IdentifyResult[] } | string {
124
+ if (!isRecord(data) || !Array.isArray(data.results)) return 'expected { modelVersion, results: [...] }'
125
+ if (typeof data.modelVersion !== 'string' || data.modelVersion.length === 0) return 'modelVersion is missing'
126
+ if (data.results.length > cropCount) return `${data.results.length} results for ${cropCount} crops`
127
+ const limit = Math.min(Math.max(top, 1), MAX_TOP)
128
+
129
+ const seen = new Set<number>()
130
+ const out: IdentifyResult[] = []
131
+ for (const [at, raw] of data.results.entries()) {
132
+ if (!isRecord(raw)) return `results[${at}] is not an object`
133
+ const { index, confident, candidates, kind, identificationId } = raw
134
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= cropCount) return `results[${at}].index is not a crop index`
135
+ if (seen.has(index)) return `index ${index} answered twice`
136
+ seen.add(index)
137
+ // An absent kind is a card (the stand-in's earlier answers had none).
138
+ if (kind !== undefined && kind !== 'card' && kind !== 'not_a_card') return `results[${at}].kind is not card or not_a_card`
139
+ const k: IdentifyKind = kind === 'not_a_card' ? 'not_a_card' : 'card'
140
+ if (typeof confident !== 'boolean') return `results[${at}].confident is not a boolean`
141
+ if (k === 'not_a_card' && confident) return `results[${at}] is a confident not_a_card`
142
+ if (!Array.isArray(candidates)) return `results[${at}].candidates is not an array`
143
+ if (candidates.length > limit) return `results[${at}] has ${candidates.length} candidates for top ${limit}`
144
+ if (identificationId !== undefined && identificationId !== null && (typeof identificationId !== 'string' || identificationId.length === 0)) {
145
+ return `results[${at}].identificationId is not a string`
146
+ }
147
+
148
+ const parsed: IdentifyCandidate[] = []
149
+ for (const [c, cand] of candidates.entries()) {
150
+ if (!isRecord(cand) || typeof cand.cardId !== 'string' || cand.cardId.length === 0) return `results[${at}].candidates[${c}].cardId is not a string`
151
+ if (typeof cand.score !== 'number' || !Number.isFinite(cand.score)) return `results[${at}].candidates[${c}].score is not a number`
152
+ const prev = parsed[parsed.length - 1]
153
+ if (prev && cand.score > prev.score) return `results[${at}].candidates are not best first`
154
+ parsed.push({ cardId: cand.cardId, score: cand.score })
155
+ }
156
+ if (confident && parsed.length === 0) return `results[${at}] is confident with no candidate`
157
+ out.push({
158
+ index,
159
+ kind: k,
160
+ confident,
161
+ candidates: parsed,
162
+ ...(typeof identificationId === 'string' ? { identificationId } : {}),
163
+ })
164
+ }
165
+ out.sort((a, b) => a.index - b.index)
166
+ return { modelVersion: data.modelVersion, results: out }
167
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Reads an index shard, `index_<domain>_v<N>.bin`, and its ids sidecar.
3
+ *
4
+ * The binary layout is `ml/src/dejadex_ml/index_format.py`'s, a cross-language
5
+ * contract (change the two together):
6
+ *
7
+ * offset size field
8
+ * 0 4 magic "DJIX"
9
+ * 4 2 format_version uint16 LE (1)
10
+ * 6 1 dtype 1 = fp16, 2 = int8
11
+ * 7 1 reserved 0
12
+ * 8 16 domain ASCII, NUL-padded
13
+ * 24 32 model_version ASCII, NUL-padded
14
+ * 56 32 index_version ASCII, NUL-padded
15
+ * 88 4 rows uint32 LE
16
+ * 92 2 dim uint16 LE
17
+ * 94 2 reserved 0
18
+ * 96 … rows × dim fp16, row-major, rows L2-normalised
19
+ *
20
+ * Only fp16 is read: the int8 layout exists in the format, and no shard ships
21
+ * with it yet.
22
+ */
23
+ import { ScannerError } from '../errors.js'
24
+
25
+ export const HEADER_SIZE = 96
26
+ const MAGIC = 'DJIX'
27
+ const FORMAT_VERSION = 1
28
+ const DTYPE_FP16 = 1
29
+ /** Typed arrays use the platform's byte order; the file is little-endian. */
30
+ const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1
31
+
32
+ export interface ShardHeader {
33
+ readonly domain: string
34
+ readonly modelVersion: string
35
+ readonly indexVersion: string
36
+ readonly rows: number
37
+ readonly dim: number
38
+ }
39
+
40
+ /** A loaded shard: rows as float32, ready for the dot products. */
41
+ export interface Shard extends ShardHeader {
42
+ /** rows × dim, row-major. */
43
+ readonly matrix: Float32Array
44
+ /** Row id per row (the catalogue's card id). */
45
+ readonly ids: readonly string[]
46
+ }
47
+
48
+ const integrity = (message: string): ScannerError => new ScannerError('MODEL_INTEGRITY', `index shard: ${message}`)
49
+
50
+ function text(bytes: Uint8Array, offset: number, size: number, name: string): string {
51
+ const raw = bytes.subarray(offset, offset + size)
52
+ const nul = raw.indexOf(0)
53
+ const end = nul === -1 ? size : nul
54
+ for (let i = end; i < size; i++) if (raw[i] !== 0) throw integrity(`${name} has bytes after its NUL padding`)
55
+ let out = ''
56
+ for (let i = 0; i < end; i++) {
57
+ const ch = raw[i] as number
58
+ if (ch < 0x20 || ch > 0x7e) throw integrity(`${name} is not printable ASCII`)
59
+ out += String.fromCharCode(ch)
60
+ }
61
+ if (out.length === 0) throw integrity(`${name} is empty`)
62
+ return out
63
+ }
64
+
65
+ export function parseShardHeader(buffer: ArrayBuffer): ShardHeader & { readonly dtype: number } {
66
+ if (buffer.byteLength < HEADER_SIZE) throw integrity(`${buffer.byteLength} bytes is shorter than its header`)
67
+ const bytes = new Uint8Array(buffer, 0, HEADER_SIZE)
68
+ const view = new DataView(buffer, 0, HEADER_SIZE)
69
+ if (String.fromCharCode(...bytes.subarray(0, 4)) !== MAGIC) throw integrity('bad magic')
70
+ const version = view.getUint16(4, true)
71
+ if (version !== FORMAT_VERSION) throw integrity(`unsupported format version ${version}`)
72
+ if (bytes[7] !== 0 || view.getUint16(94, true) !== 0) throw integrity('reserved header bytes are not zero')
73
+ return {
74
+ dtype: bytes[6] as number,
75
+ domain: text(bytes, 8, 16, 'domain'),
76
+ modelVersion: text(bytes, 24, 32, 'model version'),
77
+ indexVersion: text(bytes, 56, 32, 'index version'),
78
+ rows: view.getUint32(88, true),
79
+ dim: view.getUint16(92, true),
80
+ }
81
+ }
82
+
83
+ /** IEEE 754 half → float, by table (65 536 entries, built once). */
84
+ let HALF_TABLE: Float32Array | null = null
85
+ function halfTable(): Float32Array {
86
+ if (HALF_TABLE) return HALF_TABLE
87
+ const t = new Float32Array(65536)
88
+ for (let h = 0; h < 65536; h++) {
89
+ const sign = h & 0x8000 ? -1 : 1
90
+ const exp = (h >> 10) & 0x1f
91
+ const frac = h & 0x3ff
92
+ if (exp === 0) t[h] = sign * frac * 2 ** -24
93
+ else if (exp === 31) t[h] = frac ? Number.NaN : sign * Number.POSITIVE_INFINITY
94
+ else t[h] = sign * (1 + frac / 1024) * 2 ** (exp - 15)
95
+ }
96
+ HALF_TABLE = t
97
+ return t
98
+ }
99
+
100
+ export function halfToFloat(bits: number): number {
101
+ return halfTable()[bits & 0xffff] as number
102
+ }
103
+
104
+ export interface ShardExpectations {
105
+ readonly domain: string
106
+ readonly modelVersion: string
107
+ readonly indexVersion: string
108
+ readonly rows: number
109
+ readonly dim: number
110
+ }
111
+
112
+ /** Parses and checks the binary against what the manifest promised, and decodes it to float32. */
113
+ export function decodeShard(buffer: ArrayBuffer, ids: readonly string[], expect: ShardExpectations): Shard {
114
+ const header = parseShardHeader(buffer)
115
+ if (header.dtype !== DTYPE_FP16) throw integrity(`dtype ${header.dtype} is not supported (fp16 only)`)
116
+ for (const key of ['domain', 'modelVersion', 'indexVersion', 'rows', 'dim'] as const) {
117
+ if (header[key] !== expect[key]) {
118
+ throw integrity(`header ${key} ${String(header[key])} does not match the manifest's ${String(expect[key])}`)
119
+ }
120
+ }
121
+ const size = HEADER_SIZE + header.rows * header.dim * 2
122
+ if (buffer.byteLength !== size) throw integrity(`${buffer.byteLength} bytes, the header implies ${size}`)
123
+ if (ids.length !== header.rows) throw integrity(`${ids.length} ids for ${header.rows} rows`)
124
+ const n = header.rows * header.dim
125
+ const table = halfTable()
126
+ const matrix = new Float32Array(n)
127
+ if (LITTLE_ENDIAN) {
128
+ const halves = new Uint16Array(buffer, HEADER_SIZE, n)
129
+ for (let i = 0; i < n; i++) matrix[i] = table[halves[i] as number] as number
130
+ } else {
131
+ const view = new DataView(buffer, HEADER_SIZE)
132
+ for (let i = 0; i < n; i++) matrix[i] = table[view.getUint16(i * 2, true)] as number
133
+ }
134
+ return { domain: header.domain, modelVersion: header.modelVersion, indexVersion: header.indexVersion, rows: header.rows, dim: header.dim, matrix, ids }
135
+ }
136
+
137
+ /** The ids sidecar, `index_<domain>_v<N>.json`. */
138
+ export function parseIds(json: unknown, expect: Pick<ShardExpectations, 'domain' | 'modelVersion' | 'indexVersion' | 'rows'>): string[] {
139
+ if (typeof json !== 'object' || json === null) throw integrity('ids sidecar is not an object')
140
+ const doc = json as Record<string, unknown>
141
+ if (doc.format !== 1) throw integrity(`ids sidecar format ${String(doc.format)} is not supported`)
142
+ if (doc.shard !== expect.domain || doc.modelVersion !== expect.modelVersion || doc.indexVersion !== expect.indexVersion) {
143
+ throw integrity('ids sidecar belongs to another shard or version')
144
+ }
145
+ const ids = doc.ids
146
+ if (!Array.isArray(ids) || ids.length !== expect.rows) throw integrity(`ids sidecar lists ${Array.isArray(ids) ? ids.length : 'no'} ids for ${expect.rows} rows`)
147
+ const seen = new Set<string>()
148
+ for (const id of ids) {
149
+ if (typeof id !== 'string' || id.length === 0) throw integrity('ids sidecar has a row id that is not a string')
150
+ if (seen.has(id)) throw integrity(`row id ${id} appears twice`)
151
+ seen.add(id)
152
+ }
153
+ return ids as string[]
154
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The identifier: card crops in, catalogue ids out. This is the snapshot half
3
+ * of the SDK, usable on its own before the live pipeline (`createScanner`)
4
+ * exists. Its answer has the same shape whichever side identified the crops,
5
+ * the device or `POST /v1/identify`, so a host never needs to know which.
6
+ *
7
+ * Wire contract of the server route, which `httpIdentify` speaks:
8
+ *
9
+ * POST {serverUrl}/v1/identify multipart/form-data: crop0..crop8 (JPEG, one
10
+ * card each) and the optional text fields
11
+ * `top` (1..20), `purpose`, `session`
12
+ * Authorization: Bearer <lease token>
13
+ * → 200 { modelVersion,
14
+ * results: [ { index, kind?: 'card' | 'not_a_card', confident,
15
+ * candidates: [ { cardId, score } ], identificationId? } ] }
16
+ *
17
+ * Non-2xx answers are RFC 9457 Problem Details.
18
+ */
19
+ import type { DomainCode, LeaseClaims } from '../types.js'
20
+
21
+ /** At most this many crops per call, as on the server route. */
22
+ export const MAX_CROPS_PER_REQUEST = 9
23
+
24
+ /** Candidates per crop when `top` is not given. */
25
+ export const DEFAULT_TOP = 5
26
+
27
+ /** The most candidates a call may ask for. */
28
+ export const MAX_TOP = 20
29
+
30
+ export interface IdentifyCandidate {
31
+ /** The catalogue id of the row (the shard's row id). */
32
+ readonly cardId: string
33
+ /** Cosine similarity, the max over the orientations tried. Higher is closer. */
34
+ readonly score: number
35
+ }
36
+
37
+ /**
38
+ * What a crop is: a card (identified, or not confidently), or not a card at
39
+ * all. There is no "unknown": a card with no confident match is
40
+ * `kind: 'card', confident: false`.
41
+ */
42
+ export type IdentifyKind = 'card' | 'not_a_card'
43
+
44
+ export interface IdentifyResult {
45
+ /** Position of the crop in the call. */
46
+ readonly index: number
47
+ /** `not_a_card` when the best score is below the model version's `notACardBelow`. */
48
+ readonly kind: IdentifyKind
49
+ /** `top1 - top2 >= confidentMargin`; always false for `not_a_card`. */
50
+ readonly confident: boolean
51
+ /** Best first; `top` of them (candidates are returned for `not_a_card` too). */
52
+ readonly candidates: readonly IdentifyCandidate[]
53
+ /**
54
+ * Set when the service counted this answer as an identification itself
55
+ * (usage-metering): the SDK then writes no usage event for it.
56
+ */
57
+ readonly identificationId?: string
58
+ }
59
+
60
+ /** Where an answer came from. Informational: results are the same shape either way. */
61
+ export type IdentifySource = 'device' | 'server'
62
+
63
+ export type IdentifyFailureReason = 'unavailable' | 'timeout' | 'rejected'
64
+
65
+ export type IdentifyOutcome =
66
+ | {
67
+ readonly ok: true
68
+ /** The model version the thresholds and the candidates belong to. */
69
+ readonly modelVersion: string
70
+ readonly results: readonly IdentifyResult[]
71
+ readonly source: IdentifySource
72
+ }
73
+ | {
74
+ readonly ok: false
75
+ readonly reason: IdentifyFailureReason
76
+ /** A stable code first where there is one, e.g. `ALLOWANCE_EXHAUSTED: …`. */
77
+ readonly detail?: string
78
+ }
79
+
80
+ /** Why a crop is being identified; `retry` answers are never charged. */
81
+ export type IdentifyPurpose = 'live' | 'still' | 'retry'
82
+
83
+ export interface IdentifyCallOptions {
84
+ readonly signal?: AbortSignal
85
+ /** Candidates per crop, 1..20. Default 5. */
86
+ readonly top?: number
87
+ /**
88
+ * The host's scan session id (≤ 128 printable characters). Usage counts one
89
+ * identification per distinct card per session. Default: the identifier's
90
+ * own session, renewed by `newSession()`.
91
+ */
92
+ readonly session?: string
93
+ readonly purpose?: IdentifyPurpose
94
+ }
95
+
96
+ /** Where identification may run. `local` never sends an image anywhere. */
97
+ export type IdentificationMode = 'auto' | 'local' | 'server'
98
+
99
+ /** What the on-device engine is running on, once loaded. */
100
+ export interface DeviceEngineInfo {
101
+ readonly backend: 'webgpu' | 'wasm'
102
+ readonly precision: 'fp16' | 'fp32'
103
+ /** WASM threads in use (1 when the page is not cross-origin isolated). */
104
+ readonly threads: number
105
+ /** Measured at warm-up: one crop, every orientation, embed + search. */
106
+ readonly perCropMs: number
107
+ readonly modelVersion: string
108
+ readonly indexVersion: string
109
+ readonly shard: DomainCode
110
+ readonly rows: number
111
+ }
112
+
113
+ export interface IdentifierStatus {
114
+ readonly lease: LeaseClaims | null
115
+ /** `idle` until the first load is asked for; `failed` keeps the server path usable. */
116
+ readonly device: 'idle' | 'loading' | 'ready' | 'failed'
117
+ readonly deviceInfo: DeviceEngineInfo | null
118
+ /** Why the device engine failed to load, when it did. */
119
+ readonly deviceError: string | null
120
+ /** Why the server route is skipped right now, or null when it is usable. */
121
+ readonly serverPaused: string | null
122
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The identification Worker's entry point. `createIdentifier` starts it with
3
+ * `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })`,
4
+ * the pattern Vite, webpack 5 and Rollup bundle without configuration. A host
5
+ * that builds its own Worker points it at `@dejadex/web/identify-worker`.
6
+ *
7
+ * Everything that touches the model runs here: decoding the crops, the
8
+ * resize, the session and the search. The main thread only routes.
9
+ */
10
+ import { defaultEngineDeps } from './engine.js'
11
+ import { serveEngine, type WorkerScopeLike } from './workerProtocol.js'
12
+
13
+ serveEngine(self as unknown as WorkerScopeLike, defaultEngineDeps)
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The device engine across a Worker boundary: a request/response protocol by
3
+ * id, the Worker side (`serveEngine`) and the main-thread side
4
+ * (`connectWorkerEngine`). Crops cross as `Blob`s (structured-cloned, not
5
+ * decoded on the main thread); answers come back as plain data.
6
+ */
7
+ import { ScannerError, type ScannerErrorCode } from '../errors.js'
8
+ import type { DeviceEngine, EngineConfig, EngineDeps } from './engine.js'
9
+ import { loadDeviceEngine } from './engine.js'
10
+ import type { DeviceEngineInfo, IdentifyResult } from './types.js'
11
+
12
+ export type EngineRequest =
13
+ | { readonly id: number; readonly op: 'load'; readonly config: EngineConfig }
14
+ | { readonly id: number; readonly op: 'identify'; readonly crops: readonly Blob[]; readonly top: number }
15
+ | { readonly id: number; readonly op: 'release' }
16
+
17
+ export type EngineResponse =
18
+ | { readonly id: number; readonly ok: true; readonly info: DeviceEngineInfo | null; readonly results?: readonly IdentifyResult[] }
19
+ | { readonly id: number; readonly ok: false; readonly code: ScannerErrorCode | 'ERROR'; readonly message: string }
20
+
21
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
22
+
23
+ /** What `serveEngine` needs of `self` in a Worker. */
24
+ export interface WorkerScopeLike {
25
+ addEventListener(type: 'message', listener: (event: { readonly data: unknown }) => void): void
26
+ postMessage(message: EngineResponse): void
27
+ }
28
+
29
+ /** What `connectWorkerEngine` needs of a `Worker`. */
30
+ export interface WorkerLike {
31
+ addEventListener(type: 'message' | 'error' | 'messageerror', listener: (event: { readonly data?: unknown; readonly message?: string }) => void): void
32
+ postMessage(message: EngineRequest): void
33
+ terminate(): void
34
+ }
35
+
36
+ const failure = (id: number, err: unknown): EngineResponse =>
37
+ err instanceof ScannerError
38
+ ? { id, ok: false, code: err.code, message: err.message }
39
+ : { id, ok: false, code: 'ERROR', message: err instanceof Error ? err.message || err.name : String(err) }
40
+
41
+ /** Runs in the Worker: answers requests one at a time, in order. */
42
+ export function serveEngine(scope: WorkerScopeLike, deps: () => Promise<EngineDeps>): void {
43
+ let engine: DeviceEngine | null = null
44
+ let queue: Promise<void> = Promise.resolve()
45
+
46
+ const handle = async (req: EngineRequest): Promise<EngineResponse> => {
47
+ switch (req.op) {
48
+ case 'load':
49
+ await engine?.release()
50
+ engine = null
51
+ engine = await loadDeviceEngine(req.config, await deps())
52
+ return { id: req.id, ok: true, info: engine.info }
53
+ case 'identify': {
54
+ if (!engine) throw new ScannerError('INVALID_OPTIONS', 'the device engine is not loaded')
55
+ const results = await engine.identify(req.crops, req.top)
56
+ return { id: req.id, ok: true, info: engine.info, results }
57
+ }
58
+ case 'release':
59
+ await engine?.release()
60
+ engine = null
61
+ return { id: req.id, ok: true, info: null }
62
+ }
63
+ }
64
+
65
+ scope.addEventListener('message', (event) => {
66
+ const req = event.data as EngineRequest
67
+ if (typeof req !== 'object' || req === null || typeof req.id !== 'number') return
68
+ queue = queue.then(async () => {
69
+ let res: EngineResponse
70
+ try {
71
+ res = await handle(req)
72
+ } catch (err) {
73
+ res = failure(req.id, err)
74
+ }
75
+ scope.postMessage(res)
76
+ })
77
+ })
78
+ }
79
+
80
+ /** Main thread: loads the engine in `worker` and returns a `DeviceEngine` that forwards to it. */
81
+ export function connectWorkerEngine(worker: WorkerLike, config: EngineConfig): Promise<DeviceEngine> {
82
+ let nextId = 1
83
+ const pending = new Map<number, { resolve: (r: EngineResponse & { ok: true }) => void; reject: (e: Error) => void }>()
84
+ let dead: Error | null = null
85
+
86
+ const failAll = (err: Error) => {
87
+ dead = err
88
+ for (const p of pending.values()) p.reject(err)
89
+ pending.clear()
90
+ }
91
+ worker.addEventListener('message', (event) => {
92
+ const res = event.data as EngineResponse
93
+ const p = typeof res === 'object' && res !== null ? pending.get(res.id) : undefined
94
+ if (!p) return
95
+ pending.delete(res.id)
96
+ if (res.ok) p.resolve(res)
97
+ else p.reject(new ScannerError(res.code === 'ERROR' ? 'UNSUPPORTED_DEVICE' : res.code, res.message))
98
+ })
99
+ worker.addEventListener('error', (event) => {
100
+ failAll(new ScannerError('UNSUPPORTED_DEVICE', `the identification Worker failed: ${event.message ?? 'unknown error'}`))
101
+ })
102
+ worker.addEventListener('messageerror', () => {
103
+ failAll(new ScannerError('UNSUPPORTED_DEVICE', 'the identification Worker sent a message that could not be read'))
104
+ })
105
+
106
+ const call = (req: DistributiveOmit<EngineRequest, 'id'>): Promise<EngineResponse & { ok: true }> => {
107
+ if (dead) return Promise.reject(dead)
108
+ const id = nextId++
109
+ return new Promise((resolve, reject) => {
110
+ pending.set(id, { resolve, reject })
111
+ worker.postMessage({ ...req, id } as EngineRequest)
112
+ })
113
+ }
114
+
115
+ return call({ op: 'load', config }).then((loaded) => {
116
+ let info = loaded.info as DeviceEngineInfo
117
+ return {
118
+ get info() {
119
+ return info
120
+ },
121
+ async identify(crops, top) {
122
+ const res = await call({ op: 'identify', crops, top })
123
+ if (res.info) info = res.info
124
+ return [...(res.results ?? [])]
125
+ },
126
+ async release() {
127
+ try {
128
+ if (!dead) await call({ op: 'release' })
129
+ } finally {
130
+ worker.terminate()
131
+ failAll(new ScannerError('INVALID_OPTIONS', 'the device engine was released'))
132
+ }
133
+ },
134
+ }
135
+ })
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Public entry point of the web SDK.
3
+ *
4
+ * Real today: the homography maths (`geometry/`), and identification of card
5
+ * crops (`createIdentifier`: on the device or through the server, under a
6
+ * verified lease, with usage counted on the device). Stubbed with final
7
+ * shapes: `createScanner` (the live pipeline). See README.md.
8
+ */
9
+ export { createScanner } from './scanner.js'
10
+ export { createIdentifier, type Identifier, type IdentifierOptions } from './identify/identifier.js'
11
+ export {
12
+ DEFAULT_TOP,
13
+ MAX_CROPS_PER_REQUEST,
14
+ MAX_TOP,
15
+ type DeviceEngineInfo,
16
+ type IdentificationMode,
17
+ type IdentifierStatus,
18
+ type IdentifyCallOptions,
19
+ type IdentifyCandidate,
20
+ type IdentifyFailureReason,
21
+ type IdentifyKind,
22
+ type IdentifyOutcome,
23
+ type IdentifyPurpose,
24
+ type IdentifyResult,
25
+ type IdentifySource,
26
+ } from './identify/types.js'
27
+ export type { RuntimeOptions } from './identify/runtime.js'
28
+ export type { WorkerLike } from './identify/workerProtocol.js'
29
+ export {
30
+ LeaseVerificationError,
31
+ verifyLease,
32
+ type LeaseFailure,
33
+ type LeasePublicKey,
34
+ type VerifyLeaseOptions,
35
+ } from './lease/verify.js'
36
+ export type { DrainResult } from './usage/meter.js'
37
+ export { indexedDbUsageStore, memoryUsageStore, type UsageStore } from './usage/store.js'
38
+ export { ScannerError, type ScannerErrorCode } from './errors.js'
39
+ export {
40
+ IDENTITY,
41
+ UNIT_QUAD,
42
+ applyHomography,
43
+ invertHomography,
44
+ multiplyHomography,
45
+ quadToCssMatrix3d,
46
+ toCssMatrix3d,
47
+ unitSquareToQuad,
48
+ type ElementSize,
49
+ type Homography,
50
+ type Point,
51
+ type Quad,
52
+ } from './geometry/homography.js'
53
+ export type {
54
+ Candidate,
55
+ DeviceTier,
56
+ DomainCode,
57
+ ExternalIds,
58
+ FrameSourceInput,
59
+ Lease,
60
+ LeaseClaims,
61
+ LeaseGrant,
62
+ LeaseRequest,
63
+ PrintId,
64
+ Scanner,
65
+ ScannerOptions,
66
+ ScannerState,
67
+ TrackResult,
68
+ UsageBatch,
69
+ UsageReport,
70
+ VariantCode,
71
+ } from './types.js'