From a8071429c635003830f4476034c2896373960500 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:07:32 -0400 Subject: [PATCH 01/23] feat: verify browser bundle consumption --- README.md | 15 +- package-lock.json | 485 ++++++++++++++++++++++++++++++++++++++ package.json | 9 +- scripts/browser-smoke.mjs | 64 +++++ 4 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 scripts/browser-smoke.mjs diff --git a/README.md b/README.md index 4cb14d3..61e52ed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SIDC Kit -SIDC Kit is a small Node-focused TypeScript toolkit for working with military Symbol Identification Codes. +SIDC Kit is a small TypeScript toolkit for working with military Symbol Identification Codes in Node and browser-bundled apps. It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) renderer and adds a curated semantic layer for common workflows: @@ -51,6 +51,19 @@ const sidc = buildSidc({ const rendered = renderSymbol(sidc, { size: 40 }); ``` +## Browser Bundles + +SIDC Kit publishes browser-safe ESM at the package root. Browser and map UI builds should import from `sidc-kit` through a modern bundler: + +```ts +import { renderSymbol, searchSymbols } from "sidc-kit"; + +const match = searchSymbols("friendly infantry platoon")[0]; +const marker = renderSymbol(match.sidc, { size: 32 }); +``` + +The package runtime does not import Node built-ins. The test suite verifies browser consumption by bundling the package root with esbuild using `platform: "browser"` and exercising `renderSymbol` and `searchSymbols` from the generated bundle. Since rendering delegates to `milsymbol`, browser bundles include the `milsymbol` renderer unless your app lazy-loads this package. + ## API ### `searchSymbols(query, options?)` diff --git a/package-lock.json b/package-lock.json index 55855cf..a4f15a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,497 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "esbuild": "^0.28.1", "typescript": "^5.9.3" }, "engines": { "node": ">=20" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/milsymbol": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/milsymbol/-/milsymbol-3.0.4.tgz", diff --git a/package.json b/package.json index d5c52aa..b0278e6 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "description": "A small TypeScript toolkit for rendering SIDCs and working with curated SIDC semantics.", "type": "module", "main": "./dist/index.js", + "browser": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "browser": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" } }, "files": [ @@ -27,7 +30,8 @@ "scripts": { "build": "tsc -p tsconfig.json", "prepack": "npm run build", - "test": "npm run build && node --test" + "test": "npm run build && node --test && node scripts/browser-smoke.mjs", + "test:browser": "npm run build && node scripts/browser-smoke.mjs" }, "keywords": [ "sidc", @@ -44,6 +48,7 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "esbuild": "^0.28.1", "typescript": "^5.9.3" }, "engines": { diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs new file mode 100644 index 0000000..55d1c7d --- /dev/null +++ b/scripts/browser-smoke.mjs @@ -0,0 +1,64 @@ +import { mkdir, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import * as esbuild from "esbuild"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const smokeDir = path.join(repoRoot, ".tmp", "browser-smoke"); +const entryPath = path.join(smokeDir, "entry.mjs"); +const bundlePath = path.join(smokeDir, "bundle.mjs"); +const expectedSidc = "130310001412110000000000000000"; + +const entrySource = ` +import { renderSymbol, searchSymbols } from "sidc-kit"; + +const expectedSidc = ${JSON.stringify(expectedSidc)}; +const results = searchSymbols("friendly infantry platoon"); +if (results[0]?.sidc !== expectedSidc) { + throw new Error("searchSymbols did not return the expected infantry platoon SIDC."); +} + +const rendered = renderSymbol(expectedSidc, { size: 40 }); +if (rendered.sidc !== expectedSidc || !rendered.svg.startsWith("")) { + throw new Error("renderSymbol did not return a valid SVG payload."); +} + +export const smokeResult = { + sidc: rendered.sidc, + searchCount: results.length, + svgLength: rendered.svg.length +}; +`; + +await rm(smokeDir, { recursive: true, force: true }); +await mkdir(smokeDir, { recursive: true }); + +try { + await writeFile(entryPath, entrySource); + + await esbuild.build({ + absWorkingDir: repoRoot, + bundle: true, + conditions: ["browser", "import", "default"], + entryPoints: [entryPath], + format: "esm", + logLevel: "silent", + mainFields: ["browser", "module", "main"], + outfile: bundlePath, + platform: "browser", + target: "es2022" + }); + + const { smokeResult } = await import(pathToFileURL(bundlePath).href); + if (smokeResult?.sidc !== expectedSidc || smokeResult.searchCount < 1 || smokeResult.svgLength < 100) { + throw new Error("Bundled browser smoke result was incomplete."); + } + + const bundleStats = await stat(bundlePath); + console.log( + `Browser bundle smoke passed: esbuild platform=browser bundled ${bundleStats.size} bytes and exercised renderSymbol/searchSymbols.` + ); +} finally { + await rm(smokeDir, { recursive: true, force: true }); +} From 67c9a93274b7967a9be5cf2670225c1fbb7f09a5 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:08:21 -0400 Subject: [PATCH 02/23] feat: expand curated SIDC catalog --- README.md | 2 +- src/data/symbols.ts | 262 +++++++++++++++++++++++++++++++++++++++++ test/sidc-kit.test.mjs | 130 ++++++++++++++++++++ 3 files changed, 393 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cb14d3..d3807d6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plu ## Coverage -Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. +Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes common, verified examples across land units, air, sea surface, sea subsurface, land equipment, land installations, and control measures. Examples include infantry platoons and companies, fighter and rotary-wing aircraft, UAVs, destroyers, frigates, merchant ships, submarines, unmanned underwater vehicles, tanks, armored personnel carriers, bases, airports, checkpoints, and waypoints. Image-based reverse lookup is intentionally deferred. diff --git a/src/data/symbols.ts b/src/data/symbols.ts index 1d28f6a..750387f 100644 --- a/src/data/symbols.ts +++ b/src/data/symbols.ts @@ -132,5 +132,267 @@ export const curatedSymbols = [ entity: "reconnaissance", echelon: "platoon" } + }, + { + sidc: "130301000011010400000000000000", + name: "Friendly Air Fighter Aircraft", + aliases: [ + "friendly fighter", + "friendly fighter aircraft", + "friend fighter jet", + "blue fighter aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "fighter" + } + }, + { + sidc: "130301000011020000000000000000", + name: "Friendly Air Rotary-Wing Aircraft", + aliases: [ + "friendly rotary wing aircraft", + "friendly helicopter", + "friend helicopter", + "blue rotary wing aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "rotary wing" + } + }, + { + sidc: "130601000011030000000000000000", + name: "Hostile Air Unmanned Aerial Vehicle", + aliases: [ + "hostile uav", + "enemy uav", + "enemy drone", + "red unmanned aerial vehicle" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "hostile", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "unmanned aerial vehicle" + } + }, + { + sidc: "130330000012020300000000000000", + name: "Friendly Sea Surface Destroyer", + aliases: [ + "friendly destroyer", + "friend destroyer", + "blue destroyer", + "surface combatant destroyer" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "destroyer" + } + }, + { + sidc: "130330000012020400000000000000", + name: "Friendly Sea Surface Frigate", + aliases: [ + "friendly frigate", + "friend frigate", + "blue frigate", + "surface combatant frigate" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "frigate" + } + }, + { + sidc: "130430000014010000000000000000", + name: "Neutral Sea Surface Merchant Ship", + aliases: [ + "neutral merchant ship", + "merchant ship general", + "sea surface merchant ship", + "civilian merchant ship" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "neutral", + status: "present", + domain: "sea surface", + entity: "merchant ship", + entityType: "general" + } + }, + { + sidc: "130635000011010000000000000000", + name: "Hostile Sea Subsurface Submarine", + aliases: [ + "hostile submarine", + "enemy submarine", + "red submarine", + "sea subsurface submarine" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "hostile", + status: "present", + domain: "sea subsurface", + entity: "submarine" + } + }, + { + sidc: "130335000011040000000000000000", + name: "Friendly Sea Subsurface Unmanned Underwater Vehicle", + aliases: [ + "friendly unmanned underwater vehicle", + "friendly uuv", + "friend uuv", + "blue underwater drone" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "friend", + status: "present", + domain: "sea subsurface", + entity: "unmanned underwater vehicle" + } + }, + { + sidc: "130315000012020000000000000000", + name: "Friendly Land Equipment Tank", + aliases: [ + "friendly tank", + "friend tank", + "blue tank", + "land equipment tank" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "tank" + } + }, + { + sidc: "130315000012010300000000000000", + name: "Friendly Land Equipment Armored Personnel Carrier", + aliases: [ + "friendly armored personnel carrier", + "friendly armoured personnel carrier", + "friend apc", + "blue apc" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "armored personnel carrier" + } + }, + { + sidc: "130320000012080200000000000000", + name: "Friendly Land Installation Base", + aliases: [ + "friendly base", + "friendly military base", + "friend base", + "blue military base" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "base" + } + }, + { + sidc: "130320000012080300000000000000", + name: "Friendly Land Installation Airport", + aliases: [ + "friendly airport", + "friendly airfield", + "friend airport", + "land installation airport" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "airport" + } + }, + { + sidc: "130325000013030000000000000000", + name: "Friendly Control Measure Checkpoint", + aliases: [ + "friendly checkpoint", + "friend checkpoint", + "blue checkpoint", + "control measure checkpoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + } + }, + { + sidc: "130325000013180000000000000000", + name: "Friendly Control Measure Waypoint", + aliases: [ + "friendly waypoint", + "friend waypoint", + "blue waypoint", + "control measure waypoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "waypoint" + } } ] as const satisfies readonly CuratedSymbol[]; diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..34e037d 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -14,10 +14,41 @@ const infantryCompanySidc = "130310001512110000000000000000"; const armorPlatoonSidc = "130310001412050000000000000000"; const artilleryPlatoonSidc = "130310001413030000000000000000"; const reconnaissancePlatoonSidc = "130310001412130000000000000000"; +const friendlyAirFighterSidc = "130301000011010400000000000000"; +const friendlyAirRotaryWingSidc = "130301000011020000000000000000"; +const hostileAirUavSidc = "130601000011030000000000000000"; +const friendlySeaDestroyerSidc = "130330000012020300000000000000"; +const friendlySeaFrigateSidc = "130330000012020400000000000000"; +const neutralMerchantShipSidc = "130430000014010000000000000000"; +const hostileSubmarineSidc = "130635000011010000000000000000"; +const friendlyUuvSidc = "130335000011040000000000000000"; +const friendlyTankSidc = "130315000012020000000000000000"; +const friendlyApcSidc = "130315000012010300000000000000"; +const friendlyBaseSidc = "130320000012080200000000000000"; +const friendlyAirportSidc = "130320000012080300000000000000"; +const friendlyCheckpointSidc = "130325000013030000000000000000"; +const friendlyWaypointSidc = "130325000013180000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; +const expandedCatalogSidcs = [ + friendlyAirFighterSidc, + friendlyAirRotaryWingSidc, + hostileAirUavSidc, + friendlySeaDestroyerSidc, + friendlySeaFrigateSidc, + neutralMerchantShipSidc, + hostileSubmarineSidc, + friendlyUuvSidc, + friendlyTankSidc, + friendlyApcSidc, + friendlyBaseSidc, + friendlyAirportSidc, + friendlyCheckpointSidc, + friendlyWaypointSidc +]; + test("renderSymbol returns SVG for a known SIDC", () => { const result = renderSymbol(infantryPlatoonSidc, { size: 40 }); @@ -36,6 +67,16 @@ test("renderSymbol returns SVG for a non-curated SIDC supported by milsymbol", ( assert.match(result.svg, /<\/svg>$/); }); +test("renderSymbol returns SVG for expanded curated SIDCs", () => { + for (const sidc of expandedCatalogSidcs) { + const result = renderSymbol(sidc, { size: 40 }); + + assert.equal(result.sidc, sidc); + assert.match(result.svg, /^$/); + } +}); + test("renderSymbol rejects unsupported 30-digit SIDCs instead of returning fallback SVG", () => { for (const sidc of [unknownDimensionFallbackSidc, invalidIconFallbackSidc]) { assert.throws( @@ -59,6 +100,24 @@ test("explainSidc returns expected curated parts", () => { assert.equal(result.parts.echelon, "platoon"); }); +test("explainSidc returns expanded curated parts", () => { + const merchantShip = explainSidc(neutralMerchantShipSidc); + assert.equal(merchantShip.coverage, "curated"); + assert.equal(merchantShip.name, "Neutral Sea Surface Merchant Ship"); + assert.equal(merchantShip.parts.affiliation, "neutral"); + assert.equal(merchantShip.parts.symbolSet, "sea surface"); + assert.equal(merchantShip.parts.domain, "sea surface"); + assert.equal(merchantShip.parts.entity, "merchant ship"); + assert.equal(merchantShip.parts.entityType, "general"); + + const checkpoint = explainSidc(friendlyCheckpointSidc); + assert.equal(checkpoint.name, "Friendly Control Measure Checkpoint"); + assert.equal(checkpoint.parts.symbolSet, "control measure"); + assert.equal(checkpoint.parts.domain, "control measure"); + assert.equal(checkpoint.parts.entity, "command and control point"); + assert.equal(checkpoint.parts.entityType, "checkpoint"); +}); + test("explainSidc remains limited to curated SIDCs", () => { assert.throws( () => explainSidc(nonCuratedRenderableSidc), @@ -74,6 +133,13 @@ test("searchSymbols finds symbols by natural-language alias", () => { assert.ok(results[0].score > 0); }); +test("searchSymbols finds expanded symbols by natural-language aliases", () => { + assert.equal(searchSymbols("enemy drone")[0]?.sidc, hostileAirUavSidc); + assert.equal(searchSymbols("surface combatant destroyer")[0]?.sidc, friendlySeaDestroyerSidc); + assert.equal(searchSymbols("military base")[0]?.sidc, friendlyBaseSidc); + assert.equal(searchSymbols("control measure waypoint")[0]?.sidc, friendlyWaypointSidc); +}); + test("buildSidc creates the expected known SIDC from structured parts", () => { assert.equal( buildSidc({ @@ -86,6 +152,70 @@ test("buildSidc creates the expected known SIDC from structured parts", () => { ); }); +test("buildSidc creates expanded curated SIDCs from structured parts", () => { + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "air", + entity: "aircraft", + entityType: "fighter" + }), + friendlyAirFighterSidc + ); + assert.equal( + buildSidc({ + affiliation: "hostile", + domain: "air", + entity: "aircraft", + entityType: "unmanned aerial vehicle" + }), + hostileAirUavSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "sea surface", + entity: "surface combatant", + entityType: "frigate" + }), + friendlySeaFrigateSidc + ); + assert.equal( + buildSidc({ + affiliation: "hostile", + domain: "sea subsurface", + entity: "submarine" + }), + hostileSubmarineSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "land equipment", + entity: "tank" + }), + friendlyTankSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "land installation", + entity: "military infrastructure", + entityType: "airport" + }), + friendlyAirportSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + }), + friendlyCheckpointSidc + ); +}); + test("buildSidc uses the correct field positions for land-unit fixtures", () => { assert.equal( buildSidc({ From 54dbdd79f2a4d2bbb907c13e9f4f4313c8d7bed2 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:08:22 -0400 Subject: [PATCH 03/23] feat: add reverse lookup v0 --- README.md | 18 ++++-- src/index.ts | 144 +++++++++++++++++++++++++++++++++++++++++ test/sidc-kit.test.mjs | 44 +++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4cb14d3..290bc57 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) - explain known SIDCs into structured parts - build known SIDCs from structured parts - render SIDCs to SVG with `milsymbol` +- identify clean `milsymbol` SVG renderings against the curated set -V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, and build intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. +V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, build, and reverse lookup intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. ## Install @@ -35,7 +36,7 @@ npm test ## Usage ```ts -import { buildSidc, explainSidc, renderSymbol, searchSymbols } from "sidc-kit"; +import { buildSidc, explainSidc, identifySymbol, renderSymbol, searchSymbols } from "sidc-kit"; const results = searchSymbols("friendly infantry platoon"); @@ -49,6 +50,8 @@ const sidc = buildSidc({ }); const rendered = renderSymbol(sidc, { size: 40 }); + +const matches = identifySymbol(rendered.svg, { size: 40 }); ``` ## API @@ -70,11 +73,18 @@ Partial combinations that match more than one curated SIDC fail with `AMBIGUOUS_ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plus anchor and size metadata when available. SIDCs that `milsymbol` cannot validate or render are reported as `RENDER_FAILED`. +### `identifySymbol(input, options?)` + +Compares a clean inline SVG string, or a percent-encoded `data:image/svg+xml` URL, against normalized `milsymbol` renderings for the curated fixture set. Returns ranked candidates with `confidence` and `evidence`; exact normalized SVG matches report `confidence: 1`. + +The default `minConfidence` is `0.99`, so unrelated or weakly similar SVGs return an empty list rather than a guessed SIDC. Pass a lower `minConfidence` when you want to inspect near matches or ambiguous alternatives. + +Reverse lookup v0 is deterministic clean-rendered-input comparison. It does not recognize screenshots, photos, scanned images, raster PNG/JPEG files, cropped symbols, hand-edited icons, map marker composites, or arbitrary MIL-STD-2525/APP-6 symbols outside the curated set. + ## Coverage Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. - -Image-based reverse lookup is intentionally deferred. +Reverse lookup coverage is the same curated set and currently accepts clean SVG renderings only. ## Changelog diff --git a/src/index.ts b/src/index.ts index 8d9ffdf..fd77221 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,24 @@ export type SymbolSearchResult = ExplainSidcResult & { score: number; }; +export type IdentifySymbolOptions = RenderSymbolOptions & { + limit?: number; + minConfidence?: number; +}; + +export type IdentifySymbolEvidence = { + input: "svg"; + method: "normalized-svg"; + similarity: number; + exact: boolean; + notes: string[]; +}; + +export type IdentifySymbolResult = ExplainSidcResult & { + confidence: number; + evidence: IdentifySymbolEvidence; +}; + export type BuildSidcInput = Partial> & { affiliation: string; domain: string; @@ -48,6 +66,7 @@ export type BuildSidcInput = Partial { + const normalizedCandidate = normalizeSvgInput(renderSymbol(symbol.sidc, renderOptions).svg); + if (!normalizedCandidate) { + return undefined; + } + + const similarity = svgSimilarity(normalizedInput, normalizedCandidate); + const exact = normalizedInput === normalizedCandidate; + const confidence = roundConfidence(similarity); + + return { + ...explainSymbol(symbol), + confidence, + evidence: { + input: "svg" as const, + method: "normalized-svg" as const, + similarity: confidence, + exact, + notes: [ + exact + ? "Input SVG matches this curated milsymbol rendering after normalization." + : "Input SVG is only similar to this curated milsymbol rendering." + ] + } + }; + }) + .filter((result): result is IdentifySymbolResult => result !== undefined && result.confidence >= threshold) + .sort((left, right) => right.confidence - left.confidence || left.name.localeCompare(right.name)) + .slice(0, cappedLimit); +} + export function buildSidc(parts: BuildSidcInput): string { const wanted = normalizeParts(parts); const matches = curatedSymbols.filter((candidate) => { @@ -147,6 +208,40 @@ function normalizeSidc(sidc: string): string { return normalizedSidc; } +function normalizeSvgInput(input: string): string | undefined { + const normalized = decodeInlineSvgDataUrl(input) + .replace(/^\uFEFF/, "") + .replace(/<\?xml[\s\S]*?\?>/gi, "") + .replace(//g, "") + .trim(); + + if (!/^)/i.test(normalized)) { + return undefined; + } + + return normalized + .replace(/>\s+<") + .replace(/\s+\/>/g, "/>") + .replace(/\s+>/g, ">") + .replace(/\s*=\s*/g, "=") + .replace(/\s{2,}/g, " ") + .trim(); +} + +function decodeInlineSvgDataUrl(input: string): string { + const trimmed = input.trim(); + const dataUrl = /^data:image\/svg\+xml(?:;charset=[^;,]+)?,([\s\S]*)$/i.exec(trimmed); + if (!dataUrl) { + return trimmed; + } + + try { + return decodeURIComponent(dataUrl[1]); + } catch { + return dataUrl[1]; + } +} + function requireCuratedSidc(sidc: string): CuratedSymbol { const symbol = curatedSymbols.find((candidate) => candidate.sidc === sidc); if (!symbol) { @@ -185,6 +280,55 @@ function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[]): numb }, 0); } +function svgSimilarity(left: string, right: string): number { + if (left === right) { + return 1; + } + + const maxLength = Math.max(left.length, right.length); + if (maxLength === 0) { + return 1; + } + + return 1 - levenshteinDistance(left, right) / maxLength; +} + +function levenshteinDistance(left: string, right: string): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + current[0] = leftIndex; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const cost = left.charCodeAt(leftIndex - 1) === right.charCodeAt(rightIndex - 1) ? 0 : 1; + current[rightIndex] = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + previous[rightIndex - 1] + cost + ); + } + + for (let index = 0; index <= right.length; index += 1) { + previous[index] = current[index]; + } + } + + return previous[right.length]; +} + +function clampConfidence(value: number): number { + if (Number.isNaN(value)) { + return defaultIdentifyMinConfidence; + } + + return Math.max(0, Math.min(1, value)); +} + +function roundConfidence(value: number): number { + return Number(clampConfidence(value).toFixed(4)); +} + function tokenize(value: string): string[] { return value .toLowerCase() diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..c0804f3 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -5,6 +5,7 @@ import { SidcKitError, buildSidc, explainSidc, + identifySymbol, renderSymbol, searchSymbols } from "../dist/index.js"; @@ -74,6 +75,49 @@ test("searchSymbols finds symbols by natural-language alias", () => { assert.ok(results[0].score > 0); }); +test("identifySymbol matches a clean curated SVG rendering", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const results = identifySymbol(svg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); + assert.equal(results[0].evidence.method, "normalized-svg"); +}); + +test("identifySymbol normalizes near-exact SVG input", () => { + const svg = renderSymbol(armorPlatoonSidc, { size: 40 }).svg; + const nearExactSvg = `\n\n${svg.replaceAll("><", ">\n <")}\n`; + const dataUrlSvg = `data:image/svg+xml,${encodeURIComponent(nearExactSvg)}`; + const results = identifySymbol(dataUrlSvg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, armorPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); +}); + +test("identifySymbol returns stable ranked candidates when a broad threshold is requested", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const results = identifySymbol(svg, { size: 40, minConfidence: 0.93, limit: 4 }); + + assert.deepEqual( + results.map((result) => result.sidc), + [infantryPlatoonSidc, reconnaissancePlatoonSidc, artilleryPlatoonSidc, armorPlatoonSidc] + ); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); + assert.ok(results[1].confidence < results[0].confidence); + assert.equal(results[1].evidence.exact, false); +}); + +test("identifySymbol returns no candidates for non-milsymbol SVG input", () => { + const results = identifySymbol(''); + + assert.deepEqual(results, []); +}); + test("buildSidc creates the expected known SIDC from structured parts", () => { assert.equal( buildSidc({ From 1143ff9c274b78637edb3ba2888ce7d0615161d8 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:08:30 -0400 Subject: [PATCH 04/23] feat: improve symbol search ranking --- README.md | 4 +- src/data/symbols.ts | 14 +++- src/index.ts | 164 ++++++++++++++++++++++++++++++++++------- test/sidc-kit.test.mjs | 44 +++++++++++ 4 files changed, 197 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4cb14d3..9119a0d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ const rendered = renderSymbol(sidc, { size: 40 }); ### `searchSymbols(query, options?)` -Performs deterministic lexical matching over curated names, aliases, and part labels. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. +Performs deterministic lexical matching over curated names, aliases, and part labels. Exact names, exact aliases, exact parts, and field-specific token matches are weighted predictably, with catalog order used as the tie-breaker. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. + +Curated search terms include practical abbreviations and regional spellings for supported records, such as `inf`/`infantry`, `arty`/`artillery`, `recon`/`reconnaissance`, and `tank`/`armor`/`armour`. ### `explainSidc(sidc)` diff --git a/src/data/symbols.ts b/src/data/symbols.ts index 1d28f6a..c6f01f7 100644 --- a/src/data/symbols.ts +++ b/src/data/symbols.ts @@ -23,7 +23,9 @@ export const curatedSymbols = [ name: "Friendly Land Unit Infantry Platoon", aliases: [ "friendly infantry platoon", + "friendly inf platoon", "friend infantry platoon", + "friend inf platoon", "blue infantry platoon", "land infantry platoon" ], @@ -42,7 +44,9 @@ export const curatedSymbols = [ name: "Friendly Land Unit Infantry Company", aliases: [ "friendly infantry company", + "friendly inf company", "friend infantry company", + "friend inf company", "blue infantry company", "land infantry company" ], @@ -61,7 +65,9 @@ export const curatedSymbols = [ name: "Hostile Land Unit Infantry Platoon", aliases: [ "hostile infantry platoon", + "hostile inf platoon", "enemy infantry platoon", + "enemy inf platoon", "red infantry platoon", "land infantry platoon hostile" ], @@ -80,9 +86,12 @@ export const curatedSymbols = [ name: "Friendly Land Unit Armor Platoon", aliases: [ "friendly armor platoon", + "friendly armour platoon", + "friendly tank platoon", "friendly armoured platoon", "friend tank platoon", - "blue armor platoon" + "blue armor platoon", + "land armor platoon" ], parts: { standard: "MIL-STD-2525D/APP-6D", @@ -100,7 +109,9 @@ export const curatedSymbols = [ name: "Friendly Land Unit Artillery Platoon", aliases: [ "friendly artillery platoon", + "friendly arty platoon", "friend field artillery platoon", + "friend arty platoon", "blue artillery platoon", "land artillery platoon" ], @@ -119,6 +130,7 @@ export const curatedSymbols = [ name: "Friendly Land Unit Reconnaissance Platoon", aliases: [ "friendly reconnaissance platoon", + "friendly recon platoon", "friend recon platoon", "blue reconnaissance platoon", "land recon platoon" diff --git a/src/index.ts b/src/index.ts index 8d9ffdf..be8b7f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,44 @@ export type BuildSidcInput = Partial; +}; +type SearchFields = Record; +type FieldScoreWeights = Record; + +const exactPhraseWeights = { + names: 100, + aliases: 90, + parts: 70 +} satisfies FieldScoreWeights; + +const exactTokenWeights = { + names: 12, + aliases: 10, + parts: 8 +} satisfies FieldScoreWeights; + +const relatedTokenWeights = { + names: 7, + aliases: 6, + parts: 5 +} satisfies FieldScoreWeights; + +const partialTokenWeights = { + names: 2, + aliases: 2, + parts: 1 +} satisfies FieldScoreWeights; + +const synonymGroups = [ + ["armor", "armour", "armored", "armoured", "tank"], + ["recon", "reconnaissance"], + ["arty", "artillery"], + ["inf", "infantry"] +] as const; export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { const normalizedSidc = normalizeSidc(sidc); @@ -96,17 +134,25 @@ export function searchSymbols(query: string, options: SymbolSearchOptions = {}): } const limit = Math.max(0, options.limit ?? curatedSymbols.length); + if (limit === 0) { + return []; + } + const normalizedQuery = normalizeText(query); return curatedSymbols - .map((symbol) => { - const score = scoreSymbol(symbol, terms); + .map((symbol, index) => { + const score = scoreSymbol(symbol, terms, normalizedQuery); return { - ...explainSymbol(symbol), - score + index, + result: { + ...explainSymbol(symbol), + score + } }; }) - .filter((result) => result.score > 0) - .sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)) + .filter(({ result }) => result.score > 0) + .sort((left, right) => right.result.score - left.result.score || left.index - right.index) + .map(({ result }) => result) .slice(0, limit); } @@ -165,35 +211,99 @@ function explainSymbol(symbol: CuratedSymbol): ExplainSidcResult { }; } -function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[]): number { - const fields = [ - symbol.name, - ...symbol.aliases, - ...Object.values(symbol.parts).filter((value): value is string => typeof value === "string") - ]; - const exactText = fields.join(" ").toLowerCase(); - const fieldTerms = new Set(fields.flatMap(tokenize)); - - return queryTerms.reduce((score, term) => { - if (fieldTerms.has(term)) { - return score + 3; - } - if (exactText.includes(term)) { - return score + 1; - } - return score; - }, 0); +function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[], normalizedQuery: string): number { + const fields = buildSearchFields(symbol); + + return ( + scoreExactPhrase(normalizedQuery, fields) + + queryTerms.reduce((score, term) => score + scoreQueryTerm(term, fields), 0) + ); +} + +function buildSearchFields(symbol: CuratedSymbol): SearchFields { + const partValues = Object.values(symbol.parts).filter((value): value is string => typeof value === "string"); + + return { + names: [toSearchField(symbol.name)], + aliases: symbol.aliases.map(toSearchField), + parts: partValues.map(toSearchField) + }; +} + +function toSearchField(value: string): SearchField { + return { + text: normalizeText(value), + terms: new Set(tokenize(value)) + }; +} + +function scoreExactPhrase(query: string, fields: SearchFields): number { + return scoreFieldPhrase(query, fields.names, exactPhraseWeights.names) + + scoreFieldPhrase(query, fields.aliases, exactPhraseWeights.aliases) + + scoreFieldPhrase(query, fields.parts, exactPhraseWeights.parts); +} + +function scoreFieldPhrase(query: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text === query) ? weight : 0; +} + +function scoreQueryTerm(term: string, fields: SearchFields): number { + const exactScore = scoreTerm(term, fields, exactTokenWeights); + const relatedScore = scoreRelatedTerms(term, fields); + const partialScore = exactScore > 0 ? 0 : scorePartialTerm(term, fields); + + return exactScore + relatedScore + partialScore; +} + +function scoreRelatedTerms(term: string, fields: SearchFields): number { + const relatedTerms = expandTerm(term).filter((candidate) => candidate !== term); + if (relatedTerms.length === 0) { + return 0; + } + + return Math.max(0, ...relatedTerms.map((relatedTerm) => scoreTerm(relatedTerm, fields, relatedTokenWeights))); +} + +function expandTerm(term: string): string[] { + const group = synonymGroups.find((terms) => (terms as readonly string[]).includes(term)); + return group ? [...group] : [term]; +} + +function scoreTerm(term: string, fields: SearchFields, weights: FieldScoreWeights): number { + return scoreFieldTerm(term, fields.names, weights.names) + + scoreFieldTerm(term, fields.aliases, weights.aliases) + + scoreFieldTerm(term, fields.parts, weights.parts); +} + +function scoreFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.terms.has(term)) ? weight : 0; +} + +function scorePartialTerm(term: string, fields: SearchFields): number { + if (term.length < 3) { + return 0; + } + + return scorePartialFieldTerm(term, fields.names, partialTokenWeights.names) + + scorePartialFieldTerm(term, fields.aliases, partialTokenWeights.aliases) + + scorePartialFieldTerm(term, fields.parts, partialTokenWeights.parts); +} + +function scorePartialFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text.includes(term)) ? weight : 0; } function tokenize(value: string): string[] { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") + return normalizeText(value) .trim() .split(/\s+/) .filter(Boolean); } +function normalizeText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + function normalizeParts(parts: BuildSidcInput | SymbolParts): Record { return Object.entries(parts).reduce>((normalized, [key, value]) => { if (typeof value === "string") { diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..bbf4408 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -74,6 +74,50 @@ test("searchSymbols finds symbols by natural-language alias", () => { assert.ok(results[0].score > 0); }); +test("searchSymbols weights exact names, aliases, and parts predictably", () => { + assert.equal(searchSymbols("Friendly Land Unit Infantry Platoon")[0]?.sidc, infantryPlatoonSidc); + assert.equal(searchSymbols("friend infantry company")[0]?.sidc, infantryCompanySidc); + assert.equal(searchSymbols("tank")[0]?.sidc, armorPlatoonSidc); +}); + +test("searchSymbols supports curated synonyms and abbreviations", () => { + const cases = [ + ["friendly inf company", infantryCompanySidc], + ["friendly arty platoon", artilleryPlatoonSidc], + ["friendly recon platoon", reconnaissancePlatoonSidc], + ["friendly armour platoon", armorPlatoonSidc], + ["tank platoon", armorPlatoonSidc] + ]; + + for (const [query, sidc] of cases) { + assert.equal(searchSymbols(query)[0]?.sidc, sidc, query); + } +}); + +test("searchSymbols returns no results for empty queries", () => { + assert.deepEqual(searchSymbols(""), []); + assert.deepEqual(searchSymbols(" \t\n"), []); +}); + +test("searchSymbols respects limits", () => { + assert.deepEqual(searchSymbols("infantry", { limit: 2 }).map((result) => result.sidc), [ + infantryPlatoonSidc, + infantryCompanySidc + ]); + assert.deepEqual(searchSymbols("infantry", { limit: 0 }), []); + assert.deepEqual(searchSymbols("infantry", { limit: -1 }), []); +}); + +test("searchSymbols uses catalog order as a stable tie-breaker", () => { + assert.deepEqual(searchSymbols("friendly land", { limit: 5 }).map((result) => result.sidc), [ + infantryPlatoonSidc, + infantryCompanySidc, + armorPlatoonSidc, + artilleryPlatoonSidc, + reconnaissancePlatoonSidc + ]); +}); + test("buildSidc creates the expected known SIDC from structured parts", () => { assert.equal( buildSidc({ From d569f6540ab9e04c7b50e8c93999e86403f26d0a Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:09:54 -0400 Subject: [PATCH 05/23] feat: add curated fixture generator --- README.md | 11 + fixtures/curated-land-units.json | 87 +++++++ package.json | 6 +- scripts/generate-curated-fixtures.mjs | 320 ++++++++++++++++++++++++++ src/data/symbols.ts | 3 + 5 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 fixtures/curated-land-units.json create mode 100644 scripts/generate-curated-fixtures.mjs diff --git a/README.md b/README.md index 4cb14d3..2ea4d3a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,17 @@ npm run build npm test ``` +## Fixture Generation + +For repository development, curated fixture output is generated from `fixtures/curated-land-units.json`. + +```sh +npm run generate:fixtures +npm run check:fixtures +``` + +The generator derives number-based MIL-STD-2525D/APP-6D SIDCs from structured land-unit parts, keeps echelon/mobility in digits 9-10 and function ID in digits 11-20, and verifies each generated SIDC renders with the installed `milsymbol` package. + ## Usage ```ts diff --git a/fixtures/curated-land-units.json b/fixtures/curated-land-units.json new file mode 100644 index 0000000..cd04e4b --- /dev/null +++ b/fixtures/curated-land-units.json @@ -0,0 +1,87 @@ +[ + { + "name": "Friendly Land Unit Infantry Platoon", + "aliases": [ + "friendly infantry platoon", + "friend infantry platoon", + "blue infantry platoon", + "land infantry platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "infantry", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Infantry Company", + "aliases": [ + "friendly infantry company", + "friend infantry company", + "blue infantry company", + "land infantry company" + ], + "parts": { + "affiliation": "friend", + "entity": "infantry", + "echelon": "company" + } + }, + { + "name": "Hostile Land Unit Infantry Platoon", + "aliases": [ + "hostile infantry platoon", + "enemy infantry platoon", + "red infantry platoon", + "land infantry platoon hostile" + ], + "parts": { + "affiliation": "hostile", + "entity": "infantry", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Armor Platoon", + "aliases": [ + "friendly armor platoon", + "friendly armoured platoon", + "friend tank platoon", + "blue armor platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "armor", + "entityType": "tank", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Artillery Platoon", + "aliases": [ + "friendly artillery platoon", + "friend field artillery platoon", + "blue artillery platoon", + "land artillery platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "artillery", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Reconnaissance Platoon", + "aliases": [ + "friendly reconnaissance platoon", + "friend recon platoon", + "blue reconnaissance platoon", + "land recon platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "reconnaissance", + "echelon": "platoon" + } + } +] diff --git a/package.json b/package.json index d5c52aa..0b10042 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,10 @@ "homepage": "https://github.com/the-Drunken-coder/sidc-kit#readme", "scripts": { "build": "tsc -p tsconfig.json", - "prepack": "npm run build", - "test": "npm run build && node --test" + "check:fixtures": "node scripts/generate-curated-fixtures.mjs --check", + "generate:fixtures": "node scripts/generate-curated-fixtures.mjs", + "prepack": "npm run check:fixtures && npm run build", + "test": "npm run check:fixtures && npm run build && node --test" }, "keywords": [ "sidc", diff --git a/scripts/generate-curated-fixtures.mjs b/scripts/generate-curated-fixtures.mjs new file mode 100644 index 0000000..8e7f495 --- /dev/null +++ b/scripts/generate-curated-fixtures.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ms from "milsymbol"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const sourcePath = path.join(repoRoot, "fixtures", "curated-land-units.json"); +const targetPath = path.join(repoRoot, "src", "data", "symbols.ts"); + +const STANDARD = "MIL-STD-2525D/APP-6D"; +const SYMBOL_SET = "land unit"; +const STATUS = "present"; +const DOMAIN = "land"; + +const VERSION_CODE = "13"; +const CONTEXT_CODE = "0"; +const SYMBOL_SET_CODE = "10"; +const STATUS_CODE = "0"; +const HEADQUARTERS_TASK_FORCE_DUMMY_CODE = "0"; +const UNUSED_MODIFIER_FIELDS = "0000000000"; + +const ECHELON_START = 8; +const ECHELON_END = 10; +const FUNCTION_ID_START = 10; +const FUNCTION_ID_END = 20; + +const affiliationCodes = { + friend: "3", + hostile: "6" +}; + +const echelonCodes = { + platoon: "14", + company: "15" +}; + +const landUnitFunctionIds = { + infantry: "1211000000", + "armor:tank": "1205000000", + artillery: "1303000000", + reconnaissance: "1213000000" +}; + +const mode = parseMode(process.argv.slice(2)); + +const sourceRecords = await readSourceRecords(); +const generatedSymbols = sourceRecords.map(toGeneratedSymbol); +validateGeneratedSymbols(generatedSymbols); + +const generatedFile = renderSymbolsFile(generatedSymbols); + +if (mode === "check") { + const currentFile = await fs.readFile(targetPath, "utf8"); + if (currentFile !== generatedFile) { + throw new Error(`${path.relative(repoRoot, targetPath)} is out of date. Run npm run generate:fixtures.`); + } +} else { + await fs.writeFile(targetPath, generatedFile); +} + +function parseMode(args) { + if (args.length === 0) { + return "write"; + } + + if (args.length === 1 && args[0] === "--check") { + return "check"; + } + + throw new Error("Usage: node scripts/generate-curated-fixtures.mjs [--check]"); +} + +async function readSourceRecords() { + const rawSource = await fs.readFile(sourcePath, "utf8"); + const parsed = JSON.parse(rawSource); + + if (!Array.isArray(parsed)) { + throw new Error(`${path.relative(repoRoot, sourcePath)} must contain a JSON array.`); + } + + return parsed.map((record, index) => normalizeSourceRecord(record, index)); +} + +function normalizeSourceRecord(record, index) { + const location = `fixture ${index + 1}`; + if (!isRecord(record)) { + throw new Error(`${location} must be an object.`); + } + + const aliases = readAliases(record.aliases, `${location}.aliases`); + const sourceParts = record.parts; + if (!isRecord(sourceParts)) { + throw new Error(`${location}.parts must be an object.`); + } + + const parts = { + standard: STANDARD, + symbolSet: SYMBOL_SET, + affiliation: readString(sourceParts.affiliation, `${location}.parts.affiliation`), + status: STATUS, + domain: DOMAIN, + entity: readString(sourceParts.entity, `${location}.parts.entity`), + ...readOptionalPart(sourceParts, "entityType", `${location}.parts.entityType`), + ...readOptionalPart(sourceParts, "entitySubtype", `${location}.parts.entitySubtype`), + echelon: readString(sourceParts.echelon, `${location}.parts.echelon`) + }; + + return { + name: readString(record.name, `${location}.name`), + aliases, + parts + }; +} + +function readAliases(value, location) { + if (!Array.isArray(value)) { + throw new Error(`${location} must be an array.`); + } + + const aliases = value.map((alias, index) => readString(alias, `${location}[${index}]`)); + if (new Set(aliases).size !== aliases.length) { + throw new Error(`${location} must not contain duplicate aliases.`); + } + + return aliases; +} + +function readOptionalPart(sourceParts, key, location) { + if (!(key in sourceParts)) { + return {}; + } + + return { + [key]: readString(sourceParts[key], location) + }; +} + +function readString(value, location) { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error(`${location} must be a non-empty trimmed string.`); + } + + return value; +} + +function toGeneratedSymbol(sourceRecord) { + const { sidc, echelonCode, functionId } = buildNumberSidc(sourceRecord.parts); + const symbol = { + sidc, + name: sourceRecord.name, + aliases: sourceRecord.aliases, + parts: sourceRecord.parts + }; + + validateRenderedSymbol(symbol, { echelonCode, functionId }); + return symbol; +} + +function buildNumberSidc(parts) { + const affiliationCode = requireCode(affiliationCodes, parts.affiliation, "affiliation", parts.affiliation); + const echelonCode = requireCode(echelonCodes, parts.echelon, "echelon", parts.echelon); + const functionKey = [parts.entity, parts.entityType, parts.entitySubtype].filter(Boolean).join(":"); + const functionId = requireCode(landUnitFunctionIds, functionKey, "land-unit function ID", functionKey); + + const sidc = + VERSION_CODE + + CONTEXT_CODE + + affiliationCode + + SYMBOL_SET_CODE + + STATUS_CODE + + HEADQUARTERS_TASK_FORCE_DUMMY_CODE + + echelonCode + + functionId + + UNUSED_MODIFIER_FIELDS; + + if (!/^\d{30}$/.test(sidc)) { + throw new Error(`Generated SIDC ${sidc} must be exactly 30 digits.`); + } + + if (sidc.slice(ECHELON_START, ECHELON_END) !== echelonCode) { + throw new Error(`Generated SIDC ${sidc} must keep echelon/mobility in digits 9-10.`); + } + + if (sidc.slice(FUNCTION_ID_START, FUNCTION_ID_END) !== functionId) { + throw new Error(`Generated SIDC ${sidc} must keep function ID in digits 11-20.`); + } + + return { sidc, echelonCode, functionId }; +} + +function requireCode(codes, key, label, value) { + const code = codes[key]; + if (code) { + return code; + } + + throw new Error(`No ${label} code is configured for ${value}.`); +} + +function validateRenderedSymbol(symbol, expected) { + const rendered = new ms.Symbol(symbol.sidc, { size: 40 }); + const metadata = rendered.getMetadata(); + + if (rendered.isValid() !== true || metadata.dimensionUnknown === true) { + throw new Error(`milsymbol does not support generated SIDC ${symbol.sidc} (${symbol.name}).`); + } + + if (metadata.numberSIDC !== true) { + throw new Error(`milsymbol did not treat generated SIDC ${symbol.sidc} as a number SIDC.`); + } + + if (metadata.functionid !== expected.functionId) { + throw new Error( + `milsymbol read function ID ${String(metadata.functionid)} for ${symbol.sidc}; expected ${expected.functionId}.` + ); + } + + if (symbol.sidc.slice(ECHELON_START, ECHELON_END) !== expected.echelonCode) { + throw new Error(`Generated SIDC ${symbol.sidc} changed the expected echelon field.`); + } + + const svg = rendered.asSVG(); + if (!svg.startsWith("")) { + throw new Error(`milsymbol rendered unexpected SVG output for ${symbol.sidc}.`); + } +} + +function validateGeneratedSymbols(symbols) { + const sidcs = new Set(); + const names = new Set(); + for (const symbol of symbols) { + if (sidcs.has(symbol.sidc)) { + throw new Error(`Duplicate generated SIDC ${symbol.sidc}.`); + } + sidcs.add(symbol.sidc); + + if (names.has(symbol.name)) { + throw new Error(`Duplicate fixture name ${symbol.name}.`); + } + names.add(symbol.name); + } +} + +function renderSymbolsFile(symbols) { + return `// Generated by scripts/generate-curated-fixtures.mjs from fixtures/curated-land-units.json. +// Run npm run generate:fixtures after editing fixture source. + +export type SymbolParts = { + standard: "MIL-STD-2525D/APP-6D"; + symbolSet: string; + affiliation: string; + status: string; + domain: string; + entity: string; + entityType?: string; + entitySubtype?: string; + echelon?: string; +}; + +export type CuratedSymbol = { + sidc: string; + name: string; + aliases: readonly string[]; + parts: SymbolParts; +}; + +export const curatedSymbols = ${renderSymbols(symbols)} as const satisfies readonly CuratedSymbol[]; +`; +} + +function renderSymbols(symbols) { + return `[ +${symbols.map(renderSymbol).join(",\n")} +]`; +} + +function renderSymbol(symbol) { + return ` { + sidc: ${quote(symbol.sidc)}, + name: ${quote(symbol.name)}, + aliases: ${renderStringArray(symbol.aliases, 4)}, + parts: { +${renderPartLines(symbol.parts)} + } + }`; +} + +function renderStringArray(values, indent) { + const spaces = " ".repeat(indent); + return `[ +${values.map((value) => `${spaces} ${quote(value)}`).join(",\n")} +${spaces}]`; +} + +function renderPartLines(parts) { + const partEntries = [ + ["standard", parts.standard], + ["symbolSet", parts.symbolSet], + ["affiliation", parts.affiliation], + ["status", parts.status], + ["domain", parts.domain], + ["entity", parts.entity], + ...(parts.entityType ? [["entityType", parts.entityType]] : []), + ...(parts.entitySubtype ? [["entitySubtype", parts.entitySubtype]] : []), + ["echelon", parts.echelon] + ]; + + return partEntries.map(([key, value]) => ` ${key}: ${quote(value)}`).join(",\n"); +} + +function quote(value) { + return JSON.stringify(value); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/data/symbols.ts b/src/data/symbols.ts index 1d28f6a..f638fbe 100644 --- a/src/data/symbols.ts +++ b/src/data/symbols.ts @@ -1,3 +1,6 @@ +// Generated by scripts/generate-curated-fixtures.mjs from fixtures/curated-land-units.json. +// Run npm run generate:fixtures after editing fixture source. + export type SymbolParts = { standard: "MIL-STD-2525D/APP-6D"; symbolSet: string; From e5679df0f75d3c802a58281ee81c44920239da1f Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:10:20 -0400 Subject: [PATCH 06/23] feat: add sidc-kit CLI --- README.md | 14 ++ package-lock.json | 18 +++ package.json | 7 +- src/cli.ts | 370 ++++++++++++++++++++++++++++++++++++++++++++++ test/cli.test.mjs | 81 ++++++++++ 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 src/cli.ts create mode 100644 test/cli.test.mjs diff --git a/README.md b/README.md index 4cb14d3..897956e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,20 @@ npm test ## Usage +### CLI + +```sh +sidc-kit search "friendly infantry" --limit 3 +sidc-kit search "friendly infantry" --json +sidc-kit explain 130310001412110000000000000000 --json +sidc-kit build --affiliation friend --domain land --entity infantry --echelon platoon +sidc-kit render 130310001412110000000000000000 --size 40 > symbol.svg +``` + +The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. + +### TypeScript + ```ts import { buildSidc, explainSidc, renderSymbol, searchSymbols } from "sidc-kit"; diff --git a/package-lock.json b/package-lock.json index 55855cf..6a3c9b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,23 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "@types/node": "^20.19.43", "typescript": "^5.9.3" }, "engines": { "node": ">=20" } }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/milsymbol": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/milsymbol/-/milsymbol-3.0.4.tgz", @@ -37,6 +48,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index d5c52aa..2aff417 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "sidc-kit": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -14,7 +17,8 @@ "files": [ "dist", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "LICENSE" ], "repository": { "type": "git", @@ -44,6 +48,7 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "@types/node": "^20.19.43", "typescript": "^5.9.3" }, "engines": { diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4625bba --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env node +import { + SidcKitError, + buildSidc, + explainSidc, + renderSymbol, + searchSymbols, + type BuildSidcInput, + type RenderSymbolOptions +} from "./index.js"; + +type OptionSpec = { + kind: "flag" | "value"; +}; + +type ParsedArgs = { + positionals: string[]; + options: Map; +}; + +class UsageError extends Error { + readonly code = "USAGE_ERROR"; + + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +const commonOptions = { + help: { kind: "flag" }, + json: { kind: "flag" } +} satisfies Record; + +const helpText = `Usage: + sidc-kit search [--limit ] [--json] + sidc-kit explain [--json] + sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json] + sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json] + +Commands: + search Search curated symbols by plain-language terms. + explain Explain a curated SIDC into structured parts. + render Render any milsymbol-supported 30-digit SIDC to SVG. + build Build a curated SIDC from structured parts. +`; + +export function run(argv: readonly string[]): number { + const wantsJson = argv.includes("--json"); + + try { + if (argv.length === 0) { + throw new UsageError("Missing command. Run sidc-kit --help for usage."); + } + + const [command, ...rest] = argv; + if (command === "--help" || command === "-h") { + writeOutput(helpText); + return 0; + } + + switch (command) { + case "search": + return runSearch(rest); + case "explain": + return runExplain(rest); + case "render": + return runRender(rest); + case "build": + return runBuild(rest); + default: + throw new UsageError(`Unknown command: ${command}`); + } + } catch (error) { + reportError(error, wantsJson); + return error instanceof UsageError ? 2 : 1; + } +} + +function runSearch(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + limit: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit search [--limit ] [--json]\n"); + return 0; + } + if (parsed.positionals.length === 0) { + throw new UsageError("search requires one or more query terms."); + } + + const limitValue = getOptionalValue(parsed, "limit"); + const results = searchSymbols(parsed.positionals.join(" "), { + ...(limitValue === undefined ? {} : { limit: parseNonNegativeInteger(limitValue, "limit") }) + }); + + if (hasFlag(parsed, "json")) { + writeJson(results); + return 0; + } + + writeOutput(results.map((result) => `${result.sidc}\t${result.name}\tscore=${result.score}`).join("\n")); + if (results.length > 0) { + writeOutput("\n"); + } + return 0; +} + +function runExplain(args: readonly string[]): number { + const parsed = parseArgs(args, commonOptions); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit explain [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("explain requires exactly one SIDC."); + } + + const result = explainSidc(parsed.positionals[0]); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + writeOutput(`${result.name}\n`); + writeOutput(`SIDC: ${result.sidc}\n`); + writeOutput(`Coverage: ${result.coverage}\n`); + writeOutput("Parts:\n"); + for (const [key, value] of Object.entries(result.parts)) { + writeOutput(` ${key}: ${value}\n`); + } + return 0; +} + +function runRender(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + fill: { kind: "flag" }, + frame: { kind: "flag" }, + "no-fill": { kind: "flag" }, + "no-frame": { kind: "flag" }, + size: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("render requires exactly one SIDC."); + } + if (hasFlag(parsed, "fill") && hasFlag(parsed, "no-fill")) { + throw new UsageError("render accepts only one of --fill or --no-fill."); + } + if (hasFlag(parsed, "frame") && hasFlag(parsed, "no-frame")) { + throw new UsageError("render accepts only one of --frame or --no-frame."); + } + + const options: RenderSymbolOptions = {}; + const size = getOptionalValue(parsed, "size"); + if (size !== undefined) { + options.size = parsePositiveInteger(size, "size"); + } + if (hasFlag(parsed, "fill")) { + options.fill = true; + } + if (hasFlag(parsed, "no-fill")) { + options.fill = false; + } + if (hasFlag(parsed, "frame")) { + options.frame = true; + } + if (hasFlag(parsed, "no-frame")) { + options.frame = false; + } + + const result = renderSymbol(parsed.positionals[0], options); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + writeOutput(`${result.svg}\n`); + return 0; +} + +function runBuild(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + affiliation: { kind: "value" }, + domain: { kind: "value" }, + echelon: { kind: "value" }, + entity: { kind: "value" }, + "entity-subtype": { kind: "value" }, + "entity-type": { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput( + "Usage: sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json]\n" + ); + return 0; + } + if (parsed.positionals.length > 0) { + throw new UsageError("build accepts options only; pass parts with --affiliation, --domain, and --entity."); + } + + const input: BuildSidcInput = { + affiliation: getRequiredValue(parsed, "affiliation"), + domain: getRequiredValue(parsed, "domain"), + entity: getRequiredValue(parsed, "entity") + }; + const echelon = getOptionalValue(parsed, "echelon"); + const entityType = getOptionalValue(parsed, "entity-type"); + const entitySubtype = getOptionalValue(parsed, "entity-subtype"); + if (echelon !== undefined) { + input.echelon = echelon; + } + if (entityType !== undefined) { + input.entityType = entityType; + } + if (entitySubtype !== undefined) { + input.entitySubtype = entitySubtype; + } + + const sidc = buildSidc(input); + if (hasFlag(parsed, "json")) { + writeJson({ sidc }); + return 0; + } + + writeOutput(`${sidc}\n`); + return 0; +} + +function parseArgs(args: readonly string[], specs: Record): ParsedArgs { + const positionals: string[] = []; + const options = new Map(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + positionals.push(...args.slice(index + 1)); + break; + } + + if (arg === "-h") { + setOption(options, "help", true); + continue; + } + + if (!arg.startsWith("--")) { + if (arg.startsWith("-")) { + throw new UsageError(`Unknown option: ${arg}`); + } + positionals.push(arg); + continue; + } + + const { name, inlineValue } = splitOption(arg); + const spec = specs[name]; + if (!spec) { + throw new UsageError(`Unknown option: --${name}`); + } + + if (spec.kind === "flag") { + if (inlineValue !== undefined) { + throw new UsageError(`--${name} does not take a value.`); + } + setOption(options, name, true); + continue; + } + + if (inlineValue !== undefined) { + setOption(options, name, inlineValue); + continue; + } + + const next = args[index + 1]; + if (next === undefined || next.startsWith("-")) { + throw new UsageError(`--${name} requires a value.`); + } + setOption(options, name, next); + index += 1; + } + + return { positionals, options }; +} + +function splitOption(arg: string): { name: string; inlineValue?: string } { + const withoutPrefix = arg.slice(2); + const separator = withoutPrefix.indexOf("="); + if (separator === -1) { + return { name: withoutPrefix }; + } + + return { + name: withoutPrefix.slice(0, separator), + inlineValue: withoutPrefix.slice(separator + 1) + }; +} + +function setOption(options: Map, name: string, value: string | true): void { + if (options.has(name)) { + throw new UsageError(`Option --${name} was provided more than once.`); + } + options.set(name, value); +} + +function hasFlag(parsed: ParsedArgs, name: string): boolean { + return parsed.options.get(name) === true; +} + +function getRequiredValue(parsed: ParsedArgs, name: string): string { + const value = getOptionalValue(parsed, name); + if (value === undefined) { + throw new UsageError(`--${name} is required.`); + } + return value; +} + +function getOptionalValue(parsed: ParsedArgs, name: string): string | undefined { + const value = parsed.options.get(name); + if (value === undefined) { + return undefined; + } + if (value === true) { + throw new UsageError(`--${name} requires a value.`); + } + if (value.trim() === "") { + throw new UsageError(`--${name} cannot be empty.`); + } + return value; +} + +function parseNonNegativeInteger(value: string, name: string): number { + if (!/^\d+$/.test(value)) { + throw new UsageError(`--${name} must be a non-negative integer.`); + } + return Number(value); +} + +function parsePositiveInteger(value: string, name: string): number { + const parsed = parseNonNegativeInteger(value, name); + if (parsed === 0) { + throw new UsageError(`--${name} must be greater than zero.`); + } + return parsed; +} + +function writeJson(value: unknown): void { + writeOutput(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeOutput(value: string): void { + process.stdout.write(value); +} + +function reportError(error: unknown, wantsJson: boolean): void { + const code = error instanceof SidcKitError || error instanceof UsageError ? error.code : "UNEXPECTED_ERROR"; + const message = error instanceof Error ? error.message : String(error); + + if (wantsJson) { + process.stderr.write(`${JSON.stringify({ error: { code, message } }, null, 2)}\n`); + return; + } + + process.stderr.write(`${code}: ${message}\n`); +} + +process.exitCode = run(process.argv.slice(2)); diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..eade26b --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cliPath = path.join(rootDir, "dist", "cli.js"); +const infantryPlatoonSidc = "130310001412110000000000000000"; + +function runCli(args) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: rootDir, + encoding: "utf8" + }); +} + +test("CLI searches curated symbols with plain text output", () => { + const result = runCli(["search", "friendly", "infantry", "platoon", "--limit", "1"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`^${infantryPlatoonSidc}\\tFriendly Land Unit Infantry Platoon\\tscore=\\d+\\n$`)); +}); + +test("CLI explains a curated SIDC as JSON", () => { + const result = runCli(["explain", infantryPlatoonSidc, "--json"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.sidc, infantryPlatoonSidc); + assert.equal(parsed.name, "Friendly Land Unit Infantry Platoon"); + assert.equal(parsed.parts.echelon, "platoon"); +}); + +test("CLI builds a curated SIDC from structured options", () => { + const result = runCli([ + "build", + "--affiliation", + "friend", + "--domain", + "land", + "--entity", + "infantry", + "--echelon", + "platoon" + ]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, `${infantryPlatoonSidc}\n`); +}); + +test("CLI renders SVG to stdout by default", () => { + const result = runCli(["render", infantryPlatoonSidc, "--size", "32"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^\n$/); +}); + +test("CLI returns usage failures with exit code 2", () => { + const result = runCli(["build", "--affiliation", "friend", "--domain", "land"]); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^USAGE_ERROR: --entity is required\.\n$/); +}); + +test("CLI returns typed API failures as JSON when requested", () => { + const result = runCli(["explain", "not-a-sidc", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + + const parsed = JSON.parse(result.stderr); + assert.equal(parsed.error.code, "INVALID_SIDC"); + assert.match(parsed.error.message, /30 digits/); +}); From 8bab13d789cda122757da49048327b41094d903b Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:10:38 -0400 Subject: [PATCH 07/23] feat: add partial SIDC decomposition --- README.md | 14 ++- src/index.ts | 240 +++++++++++++++++++++++++++++++++++++---- test/sidc-kit.test.mjs | 42 +++++++- 3 files changed, 272 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 4cb14d3..b79caf7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) - build known SIDCs from structured parts - render SIDCs to SVG with `milsymbol` -V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, and build intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. +V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search and build intentionally support only a tiny curated set. Explain returns curated semantics when a SIDC is in that set and partial field decomposition for other renderable number SIDCs where `milsymbol` or the curated function-ID table provides a label. It does not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. ## Install @@ -59,7 +59,15 @@ Performs deterministic lexical matching over curated names, aliases, and part la ### `explainSidc(sidc)` -Explains a curated 30-digit SIDC into a stable JSON-serializable object. Unknown but syntactically valid SIDCs fail with `UNSUPPORTED_SIDC`. +Explains a 30-digit SIDC into a stable JSON-serializable object. + +Curated SIDCs return `coverage: "curated"` with `name`, `aliases`, and the curated `parts` object. Non-curated SIDCs that `milsymbol` can validate return `coverage: "partial"` with: + +- `parts`: only the interpreted fields +- `fields`: per-field `code`, optional `value`, and `coverage` +- `unknownFields`: field names that were present in the SIDC but not interpreted + +Unsupported or malformed SIDCs still fail with typed `SidcKitError` codes such as `INVALID_SIDC` or `UNSUPPORTED_SIDC`. ### `buildSidc(parts)` @@ -74,6 +82,8 @@ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plu Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. +Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. + Image-based reverse lookup is intentionally deferred. ## Changelog diff --git a/src/index.ts b/src/index.ts index 8d9ffdf..db951ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -import ms from "milsymbol"; +import ms, { type SymbolMetadata } from "milsymbol"; import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js"; -import { SidcKitError } from "./errors.js"; +import { SidcKitError, type SidcKitErrorCode } from "./errors.js"; export { SidcKitError } from "./errors.js"; export type { CuratedSymbol, SymbolParts } from "./data/symbols.js"; @@ -25,19 +25,48 @@ export type RenderSymbolResult = { }; }; -export type ExplainSidcResult = { +export type ExplainSidcCoverage = "curated" | "partial"; + +export type SidcFieldCoverage = "curated" | "known" | "unknown"; + +export type SidcField = { + code: string; + coverage: SidcFieldCoverage; + value?: string; +}; + +export type SidcFieldName = "affiliation" | "symbolSet" | "status" | "domain" | "echelon" | "entity"; + +export type ExplainSidcFields = Record; + +export type PartialSymbolParts = Partial; + +type BaseExplainSidcResult = { sidc: string; - name: string; aliases: string[]; + fields: ExplainSidcFields; + unknownFields: SidcFieldName[]; +}; + +export type CuratedExplainSidcResult = BaseExplainSidcResult & { + name: string; parts: SymbolParts; coverage: "curated"; }; +export type PartialExplainSidcResult = BaseExplainSidcResult & { + name?: never; + parts: PartialSymbolParts; + coverage: "partial"; +}; + +export type ExplainSidcResult = CuratedExplainSidcResult | PartialExplainSidcResult; + export type SymbolSearchOptions = { limit?: number; }; -export type SymbolSearchResult = ExplainSidcResult & { +export type SymbolSearchResult = CuratedExplainSidcResult & { score: number; }; @@ -50,16 +79,25 @@ export type BuildSidcInput = Partial; +type EntityParts = Pick & Partial>; + +const functionEntityParts = new Map( + (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ + getFunctionId(symbol.sidc), + { + entity: symbol.parts.entity, + ...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}), + ...(symbol.parts.entitySubtype ? { entitySubtype: symbol.parts.entitySubtype } : {}) + } + ]) +); export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { const normalizedSidc = normalizeSidc(sidc); try { - const symbol = new ms.Symbol(normalizedSidc, options); - const metadata = symbol.getMetadata(); - if (symbol.isValid() !== true || metadata.dimensionUnknown) { - throw new SidcKitError("RENDER_FAILED", `milsymbol does not support SIDC ${normalizedSidc}.`); - } + const { symbol } = createSupportedSymbol(normalizedSidc, options, "RENDER_FAILED", "render"); const svg = symbol.asSVG(); const anchor = toPoint(symbol.getAnchor?.()); @@ -85,8 +123,13 @@ export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): R export function explainSidc(sidc: string): ExplainSidcResult { const normalizedSidc = normalizeSidc(sidc); - const symbol = requireCuratedSidc(normalizedSidc); - return explainSymbol(symbol); + const symbol = findCuratedSidc(normalizedSidc); + if (symbol) { + return explainSymbol(symbol); + } + + const { metadata } = createSupportedSymbol(normalizedSidc, {}, "UNSUPPORTED_SIDC", "explain"); + return explainPartialSidc(normalizedSidc, metadata); } export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] { @@ -147,21 +190,34 @@ function normalizeSidc(sidc: string): string { return normalizedSidc; } -function requireCuratedSidc(sidc: string): CuratedSymbol { - const symbol = curatedSymbols.find((candidate) => candidate.sidc === sidc); - if (!symbol) { - throw new SidcKitError("UNSUPPORTED_SIDC", `SIDC ${sidc} is not in the curated V0 fixture set.`); - } - return symbol; +function findCuratedSidc(sidc: string): CuratedSymbol | undefined { + return curatedSymbols.find((candidate) => candidate.sidc === sidc); } -function explainSymbol(symbol: CuratedSymbol): ExplainSidcResult { +function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { + const fields = buildFields(symbol.sidc, symbol.parts, "curated"); return { sidc: symbol.sidc, name: symbol.name, aliases: [...symbol.aliases], parts: { ...symbol.parts }, - coverage: "curated" + coverage: "curated", + fields, + unknownFields: getUnknownFields(fields) + }; +} + +function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { + const parts = buildPartialParts(metadata); + const fields = buildFields(sidc, parts, "known"); + + return { + sidc, + aliases: [], + parts, + coverage: "partial", + fields, + unknownFields: getUnknownFields(fields) }; } @@ -228,6 +284,150 @@ function formatPartList(parts: readonly string[]): string { return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`; } +function createSupportedSymbol( + sidc: string, + options: RenderSymbolOptions, + failureCode: SidcKitErrorCode, + action: "explain" | "render" +): { symbol: MilsymbolSymbol; metadata: SymbolMetadata } { + try { + const symbol = new ms.Symbol(sidc, options); + const metadata = symbol.getMetadata(); + if (symbol.isValid() !== true || metadata.numberSIDC !== true || metadata.dimensionUnknown) { + throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`); + } + + return { symbol, metadata }; + } catch (error) { + if (error instanceof SidcKitError) { + throw error; + } + + throw new SidcKitError( + failureCode, + `Failed to ${action} SIDC ${sidc}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function buildPartialParts(metadata: SymbolMetadata): PartialSymbolParts { + const domain = normalizeDimension(metadata.dimension); + const entityParts = functionEntityParts.get(metadata.functionid); + const symbolSet = getSymbolSetLabel(domain, metadata); + const affiliation = normalizeMetadataLabel(metadata.affiliation); + const echelon = normalizeMetadataLabel(metadata.echelon); + const parts: PartialSymbolParts = { + status: getStatusLabel(metadata) + }; + + if (symbolSet) { + parts.symbolSet = symbolSet; + } + if (affiliation) { + parts.affiliation = affiliation; + } + if (domain) { + parts.domain = domain; + } + if (entityParts) { + Object.assign(parts, entityParts); + } + if (echelon) { + parts.echelon = echelon; + } + + return parts; +} + +function buildFields( + sidc: string, + parts: PartialSymbolParts, + knownCoverage: Exclude +): ExplainSidcFields { + return { + affiliation: buildField(sidc.slice(2, 4), parts.affiliation, knownCoverage), + symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage), + status: buildField(sidc.slice(6, 7), parts.status, knownCoverage), + domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage), + echelon: buildField(sidc.slice(8, 10), parts.echelon, knownCoverage), + entity: buildField(getFunctionId(sidc), parts.entity, knownCoverage) + }; +} + +function buildField( + code: string, + value: string | undefined, + knownCoverage: Exclude +): SidcField { + if (!value) { + return { + code, + coverage: "unknown" + }; + } + + return { + code, + value, + coverage: knownCoverage + }; +} + +function getUnknownFields(fields: ExplainSidcFields): SidcFieldName[] { + return Object.entries(fields) + .filter(([, field]) => field.coverage === "unknown") + .map(([fieldName]) => fieldName as SidcFieldName); +} + +function getFunctionId(sidc: string): string { + return sidc.slice(10, 20); +} + +function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined { + if (!domain) { + return undefined; + } + + if (metadata.unit === true) { + return `${domain} unit`; + } + + if (metadata.installation === true) { + return `${domain} installation`; + } + + if (metadata.activity === true) { + return `${domain} activity`; + } + + return undefined; +} + +function getStatusLabel(metadata: SymbolMetadata): string { + return ( + normalizeMetadataLabel(metadata.notpresent) ?? + normalizeMetadataLabel(metadata.condition) ?? + "present" + ); +} + +function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined { + if (value === "Ground") { + return "land"; + } + + return normalizeMetadataLabel(value); +} + +function normalizeMetadataLabel(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized || normalized === "undefined") { + return undefined; + } + + return normalized.toLowerCase(); +} + function toPoint(value: unknown): RenderSymbolResult["anchor"] { if (!isObject(value)) { return undefined; diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..09ea3a0 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -15,6 +15,7 @@ const armorPlatoonSidc = "130310001412050000000000000000"; const artilleryPlatoonSidc = "130310001413030000000000000000"; const reconnaissancePlatoonSidc = "130310001412130000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; +const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; @@ -57,15 +58,52 @@ test("explainSidc returns expected curated parts", () => { assert.equal(result.parts.domain, "land"); assert.equal(result.parts.entity, "infantry"); assert.equal(result.parts.echelon, "platoon"); + assert.equal(result.fields.entity.code, "1211000000"); + assert.equal(result.fields.entity.coverage, "curated"); + assert.deepEqual(result.unknownFields, []); }); -test("explainSidc remains limited to curated SIDCs", () => { +test("explainSidc returns partial decomposition for non-curated renderable SIDCs", () => { + const result = explainSidc(nonCuratedRenderableSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.name, undefined); + assert.deepEqual(result.aliases, []); + assert.equal(result.parts.affiliation, "neutral"); + assert.equal(result.parts.symbolSet, "land unit"); + assert.equal(result.parts.status, "present"); + assert.equal(result.parts.domain, "land"); + assert.equal(result.parts.entity, "infantry"); + assert.equal(result.parts.echelon, "platoon/detachment"); + assert.deepEqual(result.unknownFields, []); + assert.deepEqual(result.fields.affiliation, { + code: "04", + value: "neutral", + coverage: "known" + }); +}); + +test("explainSidc rejects unsupported 30-digit SIDCs with a typed error", () => { assert.throws( - () => explainSidc(nonCuratedRenderableSidc), + () => explainSidc(unknownDimensionFallbackSidc), (error) => error instanceof SidcKitError && error.code === "UNSUPPORTED_SIDC" ); }); +test("explainSidc marks unknown partial fields instead of guessing", () => { + const result = explainSidc(unknownEntitySidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.affiliation, "friend"); + assert.equal(result.parts.domain, "land"); + assert.equal(result.parts.entity, undefined); + assert.deepEqual(result.unknownFields, ["entity"]); + assert.deepEqual(result.fields.entity, { + code: "0000000000", + coverage: "unknown" + }); +}); + test("searchSymbols finds symbols by natural-language alias", () => { const results = searchSymbols("friendly infantry platoon"); From ec5bd5cf7bad3c3241a3434c1d433f8f795f5970 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 14:43:46 -0400 Subject: [PATCH 08/23] refactor: split curated catalog files --- src/data/catalog/air.ts | 61 ++++ src/data/catalog/control-measures.ts | 42 +++ src/data/catalog/index.ts | 20 ++ src/data/catalog/land-equipment.ts | 40 +++ src/data/catalog/land-installations.ts | 42 +++ src/data/catalog/land-units.ts | 119 ++++++++ src/data/catalog/sea-subsurface.ts | 40 +++ src/data/catalog/sea-surface.ts | 61 ++++ src/data/catalog/types.ts | 18 ++ src/data/symbols.ts | 400 +------------------------ test/sidc-kit.test.mjs | 36 +++ 11 files changed, 481 insertions(+), 398 deletions(-) create mode 100644 src/data/catalog/air.ts create mode 100644 src/data/catalog/control-measures.ts create mode 100644 src/data/catalog/index.ts create mode 100644 src/data/catalog/land-equipment.ts create mode 100644 src/data/catalog/land-installations.ts create mode 100644 src/data/catalog/land-units.ts create mode 100644 src/data/catalog/sea-subsurface.ts create mode 100644 src/data/catalog/sea-surface.ts create mode 100644 src/data/catalog/types.ts diff --git a/src/data/catalog/air.ts b/src/data/catalog/air.ts new file mode 100644 index 0000000..97fba42 --- /dev/null +++ b/src/data/catalog/air.ts @@ -0,0 +1,61 @@ +import type { CuratedSymbol } from "./types.js"; + +export const airSymbols = [ + { + sidc: "130301000011010400000000000000", + name: "Friendly Air Fighter Aircraft", + aliases: [ + "friendly fighter", + "friendly fighter aircraft", + "friend fighter jet", + "blue fighter aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "fighter" + } + }, + { + sidc: "130301000011020000000000000000", + name: "Friendly Air Rotary-Wing Aircraft", + aliases: [ + "friendly rotary wing aircraft", + "friendly helicopter", + "friend helicopter", + "blue rotary wing aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "rotary wing" + } + }, + { + sidc: "130601000011030000000000000000", + name: "Hostile Air Unmanned Aerial Vehicle", + aliases: [ + "hostile uav", + "enemy uav", + "enemy drone", + "red unmanned aerial vehicle" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "hostile", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "unmanned aerial vehicle" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/control-measures.ts b/src/data/catalog/control-measures.ts new file mode 100644 index 0000000..60621b4 --- /dev/null +++ b/src/data/catalog/control-measures.ts @@ -0,0 +1,42 @@ +import type { CuratedSymbol } from "./types.js"; + +export const controlMeasureSymbols = [ + { + sidc: "130325000013030000000000000000", + name: "Friendly Control Measure Checkpoint", + aliases: [ + "friendly checkpoint", + "friend checkpoint", + "blue checkpoint", + "control measure checkpoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + } + }, + { + sidc: "130325000013180000000000000000", + name: "Friendly Control Measure Waypoint", + aliases: [ + "friendly waypoint", + "friend waypoint", + "blue waypoint", + "control measure waypoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "waypoint" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/index.ts b/src/data/catalog/index.ts new file mode 100644 index 0000000..891a5e5 --- /dev/null +++ b/src/data/catalog/index.ts @@ -0,0 +1,20 @@ +import { airSymbols } from "./air.js"; +import { controlMeasureSymbols } from "./control-measures.js"; +import { landEquipmentSymbols } from "./land-equipment.js"; +import { landInstallationSymbols } from "./land-installations.js"; +import { landUnitSymbols } from "./land-units.js"; +import { seaSubsurfaceSymbols } from "./sea-subsurface.js"; +import { seaSurfaceSymbols } from "./sea-surface.js"; +import type { CuratedSymbol } from "./types.js"; + +export type { CuratedSymbol, SymbolParts } from "./types.js"; + +export const curatedSymbols = [ + ...landUnitSymbols, + ...airSymbols, + ...seaSurfaceSymbols, + ...seaSubsurfaceSymbols, + ...landEquipmentSymbols, + ...landInstallationSymbols, + ...controlMeasureSymbols +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-equipment.ts b/src/data/catalog/land-equipment.ts new file mode 100644 index 0000000..b80b9fa --- /dev/null +++ b/src/data/catalog/land-equipment.ts @@ -0,0 +1,40 @@ +import type { CuratedSymbol } from "./types.js"; + +export const landEquipmentSymbols = [ + { + sidc: "130315000012020000000000000000", + name: "Friendly Land Equipment Tank", + aliases: [ + "friendly tank", + "friend tank", + "blue tank", + "land equipment tank" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "tank" + } + }, + { + sidc: "130315000012010300000000000000", + name: "Friendly Land Equipment Armored Personnel Carrier", + aliases: [ + "friendly armored personnel carrier", + "friendly armoured personnel carrier", + "friend apc", + "blue apc" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "armored personnel carrier" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-installations.ts b/src/data/catalog/land-installations.ts new file mode 100644 index 0000000..8120d73 --- /dev/null +++ b/src/data/catalog/land-installations.ts @@ -0,0 +1,42 @@ +import type { CuratedSymbol } from "./types.js"; + +export const landInstallationSymbols = [ + { + sidc: "130320000012080200000000000000", + name: "Friendly Land Installation Base", + aliases: [ + "friendly base", + "friendly military base", + "friend base", + "blue military base" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "base" + } + }, + { + sidc: "130320000012080300000000000000", + name: "Friendly Land Installation Airport", + aliases: [ + "friendly airport", + "friendly airfield", + "friend airport", + "land installation airport" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "airport" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-units.ts b/src/data/catalog/land-units.ts new file mode 100644 index 0000000..2dfcc68 --- /dev/null +++ b/src/data/catalog/land-units.ts @@ -0,0 +1,119 @@ +import type { CuratedSymbol } from "./types.js"; + +export const landUnitSymbols = [ + { + sidc: "130310001412110000000000000000", + name: "Friendly Land Unit Infantry Platoon", + aliases: [ + "friendly infantry platoon", + "friend infantry platoon", + "blue infantry platoon", + "land infantry platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "infantry", + echelon: "platoon" + } + }, + { + sidc: "130310001512110000000000000000", + name: "Friendly Land Unit Infantry Company", + aliases: [ + "friendly infantry company", + "friend infantry company", + "blue infantry company", + "land infantry company" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "infantry", + echelon: "company" + } + }, + { + sidc: "130610001412110000000000000000", + name: "Hostile Land Unit Infantry Platoon", + aliases: [ + "hostile infantry platoon", + "enemy infantry platoon", + "red infantry platoon", + "land infantry platoon hostile" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "hostile", + status: "present", + domain: "land", + entity: "infantry", + echelon: "platoon" + } + }, + { + sidc: "130310001412050000000000000000", + name: "Friendly Land Unit Armor Platoon", + aliases: [ + "friendly armor platoon", + "friendly armoured platoon", + "friend tank platoon", + "blue armor platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "armor", + entityType: "tank", + echelon: "platoon" + } + }, + { + sidc: "130310001413030000000000000000", + name: "Friendly Land Unit Artillery Platoon", + aliases: [ + "friendly artillery platoon", + "friend field artillery platoon", + "blue artillery platoon", + "land artillery platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "artillery", + echelon: "platoon" + } + }, + { + sidc: "130310001412130000000000000000", + name: "Friendly Land Unit Reconnaissance Platoon", + aliases: [ + "friendly reconnaissance platoon", + "friend recon platoon", + "blue reconnaissance platoon", + "land recon platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "reconnaissance", + echelon: "platoon" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/sea-subsurface.ts b/src/data/catalog/sea-subsurface.ts new file mode 100644 index 0000000..4730627 --- /dev/null +++ b/src/data/catalog/sea-subsurface.ts @@ -0,0 +1,40 @@ +import type { CuratedSymbol } from "./types.js"; + +export const seaSubsurfaceSymbols = [ + { + sidc: "130635000011010000000000000000", + name: "Hostile Sea Subsurface Submarine", + aliases: [ + "hostile submarine", + "enemy submarine", + "red submarine", + "sea subsurface submarine" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "hostile", + status: "present", + domain: "sea subsurface", + entity: "submarine" + } + }, + { + sidc: "130335000011040000000000000000", + name: "Friendly Sea Subsurface Unmanned Underwater Vehicle", + aliases: [ + "friendly unmanned underwater vehicle", + "friendly uuv", + "friend uuv", + "blue underwater drone" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "friend", + status: "present", + domain: "sea subsurface", + entity: "unmanned underwater vehicle" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/sea-surface.ts b/src/data/catalog/sea-surface.ts new file mode 100644 index 0000000..210ea51 --- /dev/null +++ b/src/data/catalog/sea-surface.ts @@ -0,0 +1,61 @@ +import type { CuratedSymbol } from "./types.js"; + +export const seaSurfaceSymbols = [ + { + sidc: "130330000012020300000000000000", + name: "Friendly Sea Surface Destroyer", + aliases: [ + "friendly destroyer", + "friend destroyer", + "blue destroyer", + "surface combatant destroyer" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "destroyer" + } + }, + { + sidc: "130330000012020400000000000000", + name: "Friendly Sea Surface Frigate", + aliases: [ + "friendly frigate", + "friend frigate", + "blue frigate", + "surface combatant frigate" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "frigate" + } + }, + { + sidc: "130430000014010000000000000000", + name: "Neutral Sea Surface Merchant Ship", + aliases: [ + "neutral merchant ship", + "merchant ship general", + "sea surface merchant ship", + "civilian merchant ship" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "neutral", + status: "present", + domain: "sea surface", + entity: "merchant ship", + entityType: "general" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/types.ts b/src/data/catalog/types.ts new file mode 100644 index 0000000..0bb6366 --- /dev/null +++ b/src/data/catalog/types.ts @@ -0,0 +1,18 @@ +export type SymbolParts = { + standard: "MIL-STD-2525D/APP-6D"; + symbolSet: string; + affiliation: string; + status: string; + domain: string; + entity: string; + entityType?: string; + entitySubtype?: string; + echelon?: string; +}; + +export type CuratedSymbol = { + sidc: string; + name: string; + aliases: readonly string[]; + parts: SymbolParts; +}; diff --git a/src/data/symbols.ts b/src/data/symbols.ts index 750387f..b77d306 100644 --- a/src/data/symbols.ts +++ b/src/data/symbols.ts @@ -1,398 +1,2 @@ -export type SymbolParts = { - standard: "MIL-STD-2525D/APP-6D"; - symbolSet: string; - affiliation: string; - status: string; - domain: string; - entity: string; - entityType?: string; - entitySubtype?: string; - echelon?: string; -}; - -export type CuratedSymbol = { - sidc: string; - name: string; - aliases: readonly string[]; - parts: SymbolParts; -}; - -export const curatedSymbols = [ - { - sidc: "130310001412110000000000000000", - name: "Friendly Land Unit Infantry Platoon", - aliases: [ - "friendly infantry platoon", - "friend infantry platoon", - "blue infantry platoon", - "land infantry platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "infantry", - echelon: "platoon" - } - }, - { - sidc: "130310001512110000000000000000", - name: "Friendly Land Unit Infantry Company", - aliases: [ - "friendly infantry company", - "friend infantry company", - "blue infantry company", - "land infantry company" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "infantry", - echelon: "company" - } - }, - { - sidc: "130610001412110000000000000000", - name: "Hostile Land Unit Infantry Platoon", - aliases: [ - "hostile infantry platoon", - "enemy infantry platoon", - "red infantry platoon", - "land infantry platoon hostile" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "hostile", - status: "present", - domain: "land", - entity: "infantry", - echelon: "platoon" - } - }, - { - sidc: "130310001412050000000000000000", - name: "Friendly Land Unit Armor Platoon", - aliases: [ - "friendly armor platoon", - "friendly armoured platoon", - "friend tank platoon", - "blue armor platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "armor", - entityType: "tank", - echelon: "platoon" - } - }, - { - sidc: "130310001413030000000000000000", - name: "Friendly Land Unit Artillery Platoon", - aliases: [ - "friendly artillery platoon", - "friend field artillery platoon", - "blue artillery platoon", - "land artillery platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "artillery", - echelon: "platoon" - } - }, - { - sidc: "130310001412130000000000000000", - name: "Friendly Land Unit Reconnaissance Platoon", - aliases: [ - "friendly reconnaissance platoon", - "friend recon platoon", - "blue reconnaissance platoon", - "land recon platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "reconnaissance", - echelon: "platoon" - } - }, - { - sidc: "130301000011010400000000000000", - name: "Friendly Air Fighter Aircraft", - aliases: [ - "friendly fighter", - "friendly fighter aircraft", - "friend fighter jet", - "blue fighter aircraft" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "air", - affiliation: "friend", - status: "present", - domain: "air", - entity: "aircraft", - entityType: "fighter" - } - }, - { - sidc: "130301000011020000000000000000", - name: "Friendly Air Rotary-Wing Aircraft", - aliases: [ - "friendly rotary wing aircraft", - "friendly helicopter", - "friend helicopter", - "blue rotary wing aircraft" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "air", - affiliation: "friend", - status: "present", - domain: "air", - entity: "aircraft", - entityType: "rotary wing" - } - }, - { - sidc: "130601000011030000000000000000", - name: "Hostile Air Unmanned Aerial Vehicle", - aliases: [ - "hostile uav", - "enemy uav", - "enemy drone", - "red unmanned aerial vehicle" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "air", - affiliation: "hostile", - status: "present", - domain: "air", - entity: "aircraft", - entityType: "unmanned aerial vehicle" - } - }, - { - sidc: "130330000012020300000000000000", - name: "Friendly Sea Surface Destroyer", - aliases: [ - "friendly destroyer", - "friend destroyer", - "blue destroyer", - "surface combatant destroyer" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "sea surface", - affiliation: "friend", - status: "present", - domain: "sea surface", - entity: "surface combatant", - entityType: "destroyer" - } - }, - { - sidc: "130330000012020400000000000000", - name: "Friendly Sea Surface Frigate", - aliases: [ - "friendly frigate", - "friend frigate", - "blue frigate", - "surface combatant frigate" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "sea surface", - affiliation: "friend", - status: "present", - domain: "sea surface", - entity: "surface combatant", - entityType: "frigate" - } - }, - { - sidc: "130430000014010000000000000000", - name: "Neutral Sea Surface Merchant Ship", - aliases: [ - "neutral merchant ship", - "merchant ship general", - "sea surface merchant ship", - "civilian merchant ship" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "sea surface", - affiliation: "neutral", - status: "present", - domain: "sea surface", - entity: "merchant ship", - entityType: "general" - } - }, - { - sidc: "130635000011010000000000000000", - name: "Hostile Sea Subsurface Submarine", - aliases: [ - "hostile submarine", - "enemy submarine", - "red submarine", - "sea subsurface submarine" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "sea subsurface", - affiliation: "hostile", - status: "present", - domain: "sea subsurface", - entity: "submarine" - } - }, - { - sidc: "130335000011040000000000000000", - name: "Friendly Sea Subsurface Unmanned Underwater Vehicle", - aliases: [ - "friendly unmanned underwater vehicle", - "friendly uuv", - "friend uuv", - "blue underwater drone" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "sea subsurface", - affiliation: "friend", - status: "present", - domain: "sea subsurface", - entity: "unmanned underwater vehicle" - } - }, - { - sidc: "130315000012020000000000000000", - name: "Friendly Land Equipment Tank", - aliases: [ - "friendly tank", - "friend tank", - "blue tank", - "land equipment tank" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land equipment", - affiliation: "friend", - status: "present", - domain: "land equipment", - entity: "tank" - } - }, - { - sidc: "130315000012010300000000000000", - name: "Friendly Land Equipment Armored Personnel Carrier", - aliases: [ - "friendly armored personnel carrier", - "friendly armoured personnel carrier", - "friend apc", - "blue apc" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land equipment", - affiliation: "friend", - status: "present", - domain: "land equipment", - entity: "armored personnel carrier" - } - }, - { - sidc: "130320000012080200000000000000", - name: "Friendly Land Installation Base", - aliases: [ - "friendly base", - "friendly military base", - "friend base", - "blue military base" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land installation", - affiliation: "friend", - status: "present", - domain: "land installation", - entity: "military infrastructure", - entityType: "base" - } - }, - { - sidc: "130320000012080300000000000000", - name: "Friendly Land Installation Airport", - aliases: [ - "friendly airport", - "friendly airfield", - "friend airport", - "land installation airport" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land installation", - affiliation: "friend", - status: "present", - domain: "land installation", - entity: "military infrastructure", - entityType: "airport" - } - }, - { - sidc: "130325000013030000000000000000", - name: "Friendly Control Measure Checkpoint", - aliases: [ - "friendly checkpoint", - "friend checkpoint", - "blue checkpoint", - "control measure checkpoint" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "control measure", - affiliation: "friend", - status: "present", - domain: "control measure", - entity: "command and control point", - entityType: "checkpoint" - } - }, - { - sidc: "130325000013180000000000000000", - name: "Friendly Control Measure Waypoint", - aliases: [ - "friendly waypoint", - "friend waypoint", - "blue waypoint", - "control measure waypoint" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "control measure", - affiliation: "friend", - status: "present", - domain: "control measure", - entity: "command and control point", - entityType: "waypoint" - } - } -] as const satisfies readonly CuratedSymbol[]; +export { curatedSymbols } from "./catalog/index.js"; +export type { CuratedSymbol, SymbolParts } from "./catalog/index.js"; diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 34e037d..efaef70 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -8,6 +8,7 @@ import { renderSymbol, searchSymbols } from "../dist/index.js"; +import { curatedSymbols } from "../dist/data/catalog/index.js"; const infantryPlatoonSidc = "130310001412110000000000000000"; const infantryCompanySidc = "130310001512110000000000000000"; @@ -49,6 +50,22 @@ const expandedCatalogSidcs = [ friendlyWaypointSidc ]; +function buildInputFor(parts) { + const input = { + affiliation: parts.affiliation, + domain: parts.domain, + entity: parts.entity + }; + + for (const key of ["entityType", "entitySubtype", "echelon"]) { + if (parts[key]) { + input[key] = parts[key]; + } + } + + return input; +} + test("renderSymbol returns SVG for a known SIDC", () => { const result = renderSymbol(infantryPlatoonSidc, { size: 40 }); @@ -77,6 +94,25 @@ test("renderSymbol returns SVG for expanded curated SIDCs", () => { } }); +test("all curated catalog entries round-trip through public APIs", () => { + for (const symbol of curatedSymbols) { + const rendered = renderSymbol(symbol.sidc, { size: 32 }); + assert.match(rendered.svg, /^$/); + + const explanation = explainSidc(symbol.sidc); + assert.equal(explanation.name, symbol.name); + assert.deepEqual(explanation.parts, symbol.parts); + + assert.ok( + searchSymbols(symbol.name, { limit: curatedSymbols.length }).some((result) => result.sidc === symbol.sidc), + `Expected searchSymbols to find ${symbol.name}` + ); + + assert.equal(buildSidc(buildInputFor(symbol.parts)), symbol.sidc); + } +}); + test("renderSymbol rejects unsupported 30-digit SIDCs instead of returning fallback SVG", () => { for (const sidc of [unknownDimensionFallbackSidc, invalidIconFallbackSidc]) { assert.throws( From e531c67cd261a7e7111ed8b693f2e1a7622da2d5 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:46:42 -0400 Subject: [PATCH 09/23] test: use browser bundler defaults in smoke check --- README.md | 2 +- scripts/browser-smoke.mjs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 61e52ed..2f3beec 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ const match = searchSymbols("friendly infantry platoon")[0]; const marker = renderSymbol(match.sidc, { size: 32 }); ``` -The package runtime does not import Node built-ins. The test suite verifies browser consumption by bundling the package root with esbuild using `platform: "browser"` and exercising `renderSymbol` and `searchSymbols` from the generated bundle. Since rendering delegates to `milsymbol`, browser bundles include the `milsymbol` renderer unless your app lazy-loads this package. +The package runtime does not import Node built-ins. The test suite verifies browser consumption by bundling the package root with esbuild using `platform: "browser"` defaults and exercising `renderSymbol` and `searchSymbols` from the generated bundle. Since rendering delegates to `milsymbol`, browser bundles include the `milsymbol` renderer unless your app lazy-loads this package. ## API diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index 55d1c7d..03ee294 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -40,11 +40,9 @@ try { await esbuild.build({ absWorkingDir: repoRoot, bundle: true, - conditions: ["browser", "import", "default"], entryPoints: [entryPath], format: "esm", logLevel: "silent", - mainFields: ["browser", "module", "main"], outfile: bundlePath, platform: "browser", target: "es2022" @@ -57,7 +55,7 @@ try { const bundleStats = await stat(bundlePath); console.log( - `Browser bundle smoke passed: esbuild platform=browser bundled ${bundleStats.size} bytes and exercised renderSymbol/searchSymbols.` + `Browser bundle smoke passed: esbuild platform=browser defaults bundled ${bundleStats.size} bytes and exercised renderSymbol/searchSymbols.` ); } finally { await rm(smokeDir, { recursive: true, force: true }); From d09cad5fa623930cdfd56404fa2e70ad17d661c5 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:46:49 -0400 Subject: [PATCH 10/23] fix: harden fixture generator checks --- scripts/generate-curated-fixtures.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/generate-curated-fixtures.mjs b/scripts/generate-curated-fixtures.mjs index 8e7f495..1aa9378 100644 --- a/scripts/generate-curated-fixtures.mjs +++ b/scripts/generate-curated-fixtures.mjs @@ -54,7 +54,7 @@ const generatedFile = renderSymbolsFile(generatedSymbols); if (mode === "check") { const currentFile = await fs.readFile(targetPath, "utf8"); - if (currentFile !== generatedFile) { + if (normalizeLineEndings(currentFile) !== generatedFile) { throw new Error(`${path.relative(repoRoot, targetPath)} is out of date. Run npm run generate:fixtures.`); } } else { @@ -231,6 +231,7 @@ function validateRenderedSymbol(symbol, expected) { function validateGeneratedSymbols(symbols) { const sidcs = new Set(); const names = new Set(); + const aliases = new Set(); for (const symbol of symbols) { if (sidcs.has(symbol.sidc)) { throw new Error(`Duplicate generated SIDC ${symbol.sidc}.`); @@ -241,9 +242,21 @@ function validateGeneratedSymbols(symbols) { throw new Error(`Duplicate fixture name ${symbol.name}.`); } names.add(symbol.name); + + for (const alias of symbol.aliases) { + const normalizedAlias = alias.toLowerCase(); + if (aliases.has(normalizedAlias)) { + throw new Error(`Duplicate fixture alias ${alias}.`); + } + aliases.add(normalizedAlias); + } } } +function normalizeLineEndings(value) { + return value.replace(/\r\n/g, "\n"); +} + function renderSymbolsFile(symbols) { return `// Generated by scripts/generate-curated-fixtures.mjs from fixtures/curated-land-units.json. // Run npm run generate:fixtures after editing fixture source. From b219a74d985f9ed5415382bf79ba1d35fce6c1e4 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:48:41 -0400 Subject: [PATCH 11/23] fix: tighten partial SIDC coverage --- README.md | 2 +- src/index.ts | 31 +++++++++++++++++++------------ test/sidc-kit.test.mjs | 13 +++++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b79caf7..e2cb224 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plu Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. -Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. +Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`. Image-based reverse lookup is intentionally deferred. diff --git a/src/index.ts b/src/index.ts index db951ea..7614f9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,7 +208,7 @@ function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { } function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { - const parts = buildPartialParts(metadata); + const parts = buildPartialParts(sidc, metadata); const fields = buildFields(sidc, parts, "known"); return { @@ -293,7 +293,7 @@ function createSupportedSymbol( try { const symbol = new ms.Symbol(sidc, options); const metadata = symbol.getMetadata(); - if (symbol.isValid() !== true || metadata.numberSIDC !== true || metadata.dimensionUnknown) { + if (symbol.isValid() !== true || metadata.dimensionUnknown) { throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`); } @@ -310,15 +310,14 @@ function createSupportedSymbol( } } -function buildPartialParts(metadata: SymbolMetadata): PartialSymbolParts { +function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { const domain = normalizeDimension(metadata.dimension); const entityParts = functionEntityParts.get(metadata.functionid); const symbolSet = getSymbolSetLabel(domain, metadata); const affiliation = normalizeMetadataLabel(metadata.affiliation); + const status = getStatusLabel(sidc.slice(6, 7), metadata); const echelon = normalizeMetadataLabel(metadata.echelon); - const parts: PartialSymbolParts = { - status: getStatusLabel(metadata) - }; + const parts: PartialSymbolParts = {}; if (symbolSet) { parts.symbolSet = symbolSet; @@ -326,6 +325,9 @@ function buildPartialParts(metadata: SymbolMetadata): PartialSymbolParts { if (affiliation) { parts.affiliation = affiliation; } + if (status) { + parts.status = status; + } if (domain) { parts.domain = domain; } @@ -403,12 +405,17 @@ function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata) return undefined; } -function getStatusLabel(metadata: SymbolMetadata): string { - return ( - normalizeMetadataLabel(metadata.notpresent) ?? - normalizeMetadataLabel(metadata.condition) ?? - "present" - ); +function getStatusLabel(statusCode: string, metadata: SymbolMetadata): string | undefined { + const condition = normalizeMetadataLabel(metadata.condition); + if (condition) { + return condition; + } + + if (statusCode === "0") { + return "present"; + } + + return undefined; } function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined { diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 09ea3a0..5a4d8db 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -15,6 +15,7 @@ const armorPlatoonSidc = "130310001412050000000000000000"; const artilleryPlatoonSidc = "130310001413030000000000000000"; const reconnaissancePlatoonSidc = "130310001412130000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; +const nonCuratedUnlabeledStatusSidc = "130410101412110000000000000000"; const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; @@ -83,6 +84,18 @@ test("explainSidc returns partial decomposition for non-curated renderable SIDCs }); }); +test("explainSidc marks unlabeled non-present status unknown", () => { + const result = explainSidc(nonCuratedUnlabeledStatusSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.status, undefined); + assert.deepEqual(result.fields.status, { + code: "1", + coverage: "unknown" + }); + assert.deepEqual(result.unknownFields, ["status"]); +}); + test("explainSidc rejects unsupported 30-digit SIDCs with a typed error", () => { assert.throws( () => explainSidc(unknownDimensionFallbackSidc), From dd979ce1eb3f44e0661dd2269714111d3544563a Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:49:02 -0400 Subject: [PATCH 12/23] fix: harden CLI argument handling --- README.md | 2 +- src/cli.ts | 53 ++++++++++++++++++++++++++++++++++----- test/cli.test.mjs | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 897956e..a572948 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ sidc-kit build --affiliation friend --domain land --entity infantry --echelon pl sidc-kit render 130310001412110000000000000000 --size 40 > symbol.svg ``` -The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. +The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. Render size must be between 1 and 4096 pixels. ### TypeScript diff --git a/src/cli.ts b/src/cli.ts index 4625bba..522c0ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + import { SidcKitError, buildSidc, @@ -32,6 +36,8 @@ const commonOptions = { json: { kind: "flag" } } satisfies Record; +const maxRenderSize = 4096; + const helpText = `Usage: sidc-kit search [--limit ] [--json] sidc-kit explain [--json] @@ -46,7 +52,7 @@ Commands: `; export function run(argv: readonly string[]): number { - const wantsJson = argv.includes("--json"); + const wantsJson = hasJsonFlagBeforeTerminator(argv); try { if (argv.length === 0) { @@ -159,7 +165,7 @@ function runRender(args: readonly string[]): number { const options: RenderSymbolOptions = {}; const size = getOptionalValue(parsed, "size"); if (size !== undefined) { - options.size = parsePositiveInteger(size, "size"); + options.size = parsePositiveInteger(size, "size", maxRenderSize); } if (hasFlag(parsed, "fill")) { options.fill = true; @@ -276,7 +282,7 @@ function parseArgs(args: readonly string[], specs: Record): } const next = args[index + 1]; - if (next === undefined || next.startsWith("-")) { + if (next === undefined || next === "--" || next.startsWith("--")) { throw new UsageError(`--${name} requires a value.`); } setOption(options, name, next); @@ -336,17 +342,37 @@ function parseNonNegativeInteger(value: string, name: string): number { if (!/^\d+$/.test(value)) { throw new UsageError(`--${name} must be a non-negative integer.`); } - return Number(value); + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new UsageError(`--${name} must be a safe integer.`); + } + return parsed; } -function parsePositiveInteger(value: string, name: string): number { +function parsePositiveInteger(value: string, name: string, max?: number): number { const parsed = parseNonNegativeInteger(value, name); if (parsed === 0) { throw new UsageError(`--${name} must be greater than zero.`); } + if (max !== undefined && parsed > max) { + throw new UsageError(`--${name} must be no greater than ${max}.`); + } return parsed; } +function hasJsonFlagBeforeTerminator(argv: readonly string[]): boolean { + for (const arg of argv) { + if (arg === "--") { + return false; + } + if (arg === "--json") { + return true; + } + } + return false; +} + function writeJson(value: unknown): void { writeOutput(`${JSON.stringify(value, null, 2)}\n`); } @@ -367,4 +393,19 @@ function reportError(error: unknown, wantsJson: boolean): void { process.stderr.write(`${code}: ${message}\n`); } -process.exitCode = run(process.argv.slice(2)); +function isCliEntryPoint(): boolean { + const entryPoint = process.argv[1]; + if (!entryPoint) { + return false; + } + + try { + return realpathSync(resolve(entryPoint)) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isCliEntryPoint()) { + process.exitCode = run(process.argv.slice(2)); +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs index eade26b..3392f52 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -15,6 +15,35 @@ function runCli(args) { }); } +test("CLI module import does not execute the command runner", async () => { + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + const originalStderrWrite = process.stderr.write; + let stderr = ""; + + try { + process.argv = [process.execPath, "--test"]; + process.exitCode = undefined; + process.stderr.write = (chunk, ...args) => { + stderr += String(chunk); + const callback = args.find((arg) => typeof arg === "function"); + if (callback) { + callback(); + } + return true; + }; + + await import(`${new URL("../dist/cli.js", import.meta.url).href}?import-side-effect=${Date.now()}`); + + assert.equal(process.exitCode, undefined); + assert.equal(stderr, ""); + } finally { + process.argv = originalArgv; + process.exitCode = originalExitCode; + process.stderr.write = originalStderrWrite; + } +}); + test("CLI searches curated symbols with plain text output", () => { const result = runCli(["search", "friendly", "infantry", "platoon", "--limit", "1"]); @@ -79,3 +108,37 @@ test("CLI returns typed API failures as JSON when requested", () => { assert.equal(parsed.error.code, "INVALID_SIDC"); assert.match(parsed.error.message, /30 digits/); }); + +test("CLI ignores --json after the positional terminator", () => { + const result = runCli(["explain", "--", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^INVALID_SIDC: SIDC must be exactly 30 digits\.\n$/); + assert.throws(() => JSON.parse(result.stderr), SyntaxError); +}); + +test("CLI gives consistent diagnostics for separated negative numeric values", () => { + const separated = runCli(["search", "infantry", "--limit", "-1"]); + const inline = runCli(["search", "infantry", "--limit=-1"]); + + assert.equal(separated.status, 2); + assert.equal(inline.status, 2); + assert.equal(separated.stdout, ""); + assert.equal(inline.stdout, ""); + assert.equal(separated.stderr, inline.stderr); + assert.match(separated.stderr, /^USAGE_ERROR: --limit must be a non-negative integer\.\n$/); +}); + +test("CLI rejects unsafe and oversized integer values", () => { + const unsafe = runCli(["render", infantryPlatoonSidc, "--size", "999999999999999999999999999999"]); + const oversized = runCli(["render", infantryPlatoonSidc, "--size", "4097"]); + + assert.equal(unsafe.status, 2); + assert.equal(unsafe.stdout, ""); + assert.match(unsafe.stderr, /^USAGE_ERROR: --size must be a safe integer\.\n$/); + + assert.equal(oversized.status, 2); + assert.equal(oversized.stdout, ""); + assert.match(oversized.stderr, /^USAGE_ERROR: --size must be no greater than 4096\.\n$/); +}); From a307b1bc9a1b9a9411a1a9d8aa37db2889d70177 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:49:24 -0400 Subject: [PATCH 13/23] fix: harden reverse lookup matching --- README.md | 2 +- src/index.ts | 47 +++++++++++++++++++++++++++--------------- test/sidc-kit.test.mjs | 20 +++++++++++++++++- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 290bc57..5349630 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Compares a clean inline SVG string, or a percent-encoded `data:image/svg+xml` UR The default `minConfidence` is `0.99`, so unrelated or weakly similar SVGs return an empty list rather than a guessed SIDC. Pass a lower `minConfidence` when you want to inspect near matches or ambiguous alternatives. -Reverse lookup v0 is deterministic clean-rendered-input comparison. It does not recognize screenshots, photos, scanned images, raster PNG/JPEG files, cropped symbols, hand-edited icons, map marker composites, or arbitrary MIL-STD-2525/APP-6 symbols outside the curated set. +Reverse lookup v0 is deterministic clean-rendered-input comparison. Oversized SVG input is rejected before fuzzy scoring. It does not recognize screenshots, photos, scanned images, raster PNG/JPEG files, cropped symbols, hand-edited icons, map marker composites, or arbitrary MIL-STD-2525/APP-6 symbols outside the curated set. ## Coverage diff --git a/src/index.ts b/src/index.ts index fd77221..49f5de4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export type BuildSidcInput = Partial result !== undefined && result.confidence >= threshold) - .sort((left, right) => right.confidence - left.confidence || left.name.localeCompare(right.name)) - .slice(0, cappedLimit); + .filter((candidate): candidate is { similarity: number; result: IdentifySymbolResult } => candidate !== undefined) + .sort((left, right) => right.similarity - left.similarity || left.result.name.localeCompare(right.result.name)) + .slice(0, cappedLimit) + .map((candidate) => candidate.result); } export function buildSidc(parts: BuildSidcInput): string { @@ -215,6 +224,10 @@ function normalizeSvgInput(input: string): string | undefined { .replace(//g, "") .trim(); + if (normalized.length > maxIdentifySvgInputLength) { + return undefined; + } + if (!/^)/i.test(normalized)) { return undefined; } @@ -230,7 +243,7 @@ function normalizeSvgInput(input: string): string | undefined { function decodeInlineSvgDataUrl(input: string): string { const trimmed = input.trim(); - const dataUrl = /^data:image\/svg\+xml(?:;charset=[^;,]+)?,([\s\S]*)$/i.exec(trimmed); + const dataUrl = /^data:image\/svg\+xml(?:;[^,]+)*,([\s\S]*)$/i.exec(trimmed); if (!dataUrl) { return trimmed; } @@ -326,7 +339,7 @@ function clampConfidence(value: number): number { } function roundConfidence(value: number): number { - return Number(clampConfidence(value).toFixed(4)); + return Number(clampConfidence(value).toFixed(6)); } function tokenize(value: string): string[] { diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index c0804f3..fc7cca3 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -89,7 +89,7 @@ test("identifySymbol matches a clean curated SVG rendering", () => { test("identifySymbol normalizes near-exact SVG input", () => { const svg = renderSymbol(armorPlatoonSidc, { size: 40 }).svg; const nearExactSvg = `\n\n${svg.replaceAll("><", ">\n <")}\n`; - const dataUrlSvg = `data:image/svg+xml,${encodeURIComponent(nearExactSvg)}`; + const dataUrlSvg = `data:image/svg+xml;utf8,${encodeURIComponent(nearExactSvg)}`; const results = identifySymbol(dataUrlSvg, { size: 40 }); assert.equal(results.length, 1); @@ -112,12 +112,30 @@ test("identifySymbol returns stable ranked candidates when a broad threshold is assert.equal(results[1].evidence.exact, false); }); +test("identifySymbol applies minConfidence to raw similarity before rounding", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const nearMatchSvg = svg.replace('baseProfile="tiny"', 'baseProfile="Tiny"'); + const threshold = 0.998301; + const results = identifySymbol(nearMatchSvg, { size: 40, minConfidence: threshold }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.ok(results[0].confidence >= threshold); + assert.equal(results[0].evidence.exact, false); +}); + test("identifySymbol returns no candidates for non-milsymbol SVG input", () => { const results = identifySymbol(''); assert.deepEqual(results, []); }); +test("identifySymbol rejects oversized SVG input before fuzzy comparison", () => { + const oversizedSvg = `${"x".repeat(10_001)}`; + + assert.deepEqual(identifySymbol(oversizedSvg, { minConfidence: 0 }), []); +}); + test("buildSidc creates the expected known SIDC from structured parts", () => { assert.equal( buildSidc({ From 501bf65d5ae6819c446cdba74d88d545eca2c25d Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:51:12 -0400 Subject: [PATCH 14/23] fix: avoid unnecessary reverse lookup scoring --- src/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 49f5de4..553a483 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,7 +147,7 @@ export function identifySymbol(input: string, options: IdentifySymbolOptions = { return undefined; } - const similarity = svgSimilarity(normalizedInput, normalizedCandidate); + const similarity = svgSimilarity(normalizedInput, normalizedCandidate, threshold); const exact = normalizedInput === normalizedCandidate; if (similarity < threshold) { return undefined; @@ -293,7 +293,7 @@ function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[]): numb }, 0); } -function svgSimilarity(left: string, right: string): number { +function svgSimilarity(left: string, right: string, minSimilarity: number): number { if (left === right) { return 1; } @@ -303,6 +303,11 @@ function svgSimilarity(left: string, right: string): number { return 1; } + const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; + if (lengthSimilarity < minSimilarity) { + return lengthSimilarity; + } + return 1 - levenshteinDistance(left, right) / maxLength; } From cf37a7c99fe0c6d6da032b30f3024cbae0cec5ab Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 17:01:44 -0400 Subject: [PATCH 15/23] docs: integrate feature README updates --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4cb14d3..4082e62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SIDC Kit -SIDC Kit is a small Node-focused TypeScript toolkit for working with military Symbol Identification Codes. +SIDC Kit is a small TypeScript toolkit for working with military Symbol Identification Codes in Node and browser-bundled apps. It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) renderer and adds a curated semantic layer for common workflows: @@ -8,12 +8,13 @@ It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) - explain known SIDCs into structured parts - build known SIDCs from structured parts - render SIDCs to SVG with `milsymbol` +- identify clean `milsymbol` SVG renderings against the curated set -V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, and build intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. +V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, build, and reverse lookup intentionally support only a curated set. Explain returns curated semantics when a SIDC is in that set and partial field decomposition for other renderable number SIDCs where `milsymbol` or the curated function-ID table provides a label. It does not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. ## Install -Install from npm after the first release is published: +Install from npm: ```sh npm install sidc-kit @@ -32,10 +33,35 @@ npm run build npm test ``` +## Fixture Generation + +For repository development, generated land-unit catalog entries come from `fixtures/curated-land-units.json` and are written into `src/data/catalog/land-units.ts`. + +```sh +npm run generate:fixtures +npm run check:fixtures +``` + +The generator derives number-based MIL-STD-2525D/APP-6D SIDCs from structured land-unit parts, keeps echelon/mobility in digits 9-10 and function ID in digits 11-20, and verifies each generated SIDC renders with the installed `milsymbol` package. + ## Usage +### CLI + +```sh +sidc-kit search "friendly infantry" --limit 3 +sidc-kit search "friendly infantry" --json +sidc-kit explain 130310001412110000000000000000 --json +sidc-kit build --affiliation friend --domain land --entity infantry --echelon platoon +sidc-kit render 130310001412110000000000000000 --size 40 > symbol.svg +``` + +The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. Render size must be between 1 and 4096 pixels. + +### TypeScript + ```ts -import { buildSidc, explainSidc, renderSymbol, searchSymbols } from "sidc-kit"; +import { buildSidc, explainSidc, identifySymbol, renderSymbol, searchSymbols } from "sidc-kit"; const results = searchSymbols("friendly infantry platoon"); @@ -49,17 +75,42 @@ const sidc = buildSidc({ }); const rendered = renderSymbol(sidc, { size: 40 }); + +const matches = identifySymbol(rendered.svg, { size: 40 }); +``` + +## Browser Bundles + +SIDC Kit publishes browser-safe ESM at the package root. Browser and map UI builds should import from `sidc-kit` through a modern bundler: + +```ts +import { renderSymbol, searchSymbols } from "sidc-kit"; + +const match = searchSymbols("friendly infantry platoon")[0]; +const marker = renderSymbol(match.sidc, { size: 32 }); ``` +The package runtime does not import Node built-ins. The test suite verifies browser consumption by bundling the package root with esbuild using `platform: "browser"` defaults and exercising `renderSymbol` and `searchSymbols` from the generated bundle. Since rendering delegates to `milsymbol`, browser bundles include the `milsymbol` renderer unless your app lazy-loads this package. + ## API ### `searchSymbols(query, options?)` -Performs deterministic lexical matching over curated names, aliases, and part labels. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. +Performs deterministic lexical matching over curated names, aliases, and part labels. Exact names, exact aliases, exact parts, and field-specific token matches are weighted predictably, with catalog order used as the tie-breaker. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. + +Curated search terms include practical abbreviations and regional spellings for supported records, such as `inf`/`infantry`, `arty`/`artillery`, `recon`/`reconnaissance`, and `tank`/`armor`/`armour`. ### `explainSidc(sidc)` -Explains a curated 30-digit SIDC into a stable JSON-serializable object. Unknown but syntactically valid SIDCs fail with `UNSUPPORTED_SIDC`. +Explains a 30-digit SIDC into a stable JSON-serializable object. + +Curated SIDCs return `coverage: "curated"` with `name`, `aliases`, and the curated `parts` object. Non-curated SIDCs that `milsymbol` can validate return `coverage: "partial"` with: + +- `parts`: only the interpreted fields +- `fields`: per-field `code`, optional `value`, and `coverage` +- `unknownFields`: field names that were present in the SIDC but not interpreted + +Unsupported or malformed SIDCs still fail with typed `SidcKitError` codes such as `INVALID_SIDC` or `UNSUPPORTED_SIDC`. ### `buildSidc(parts)` @@ -70,9 +121,21 @@ Partial combinations that match more than one curated SIDC fail with `AMBIGUOUS_ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plus anchor and size metadata when available. SIDCs that `milsymbol` cannot validate or render are reported as `RENDER_FAILED`. +### `identifySymbol(input, options?)` + +Compares a clean inline SVG string, or a percent-encoded `data:image/svg+xml` URL, against normalized `milsymbol` renderings for the curated fixture set. Returns ranked candidates with `confidence` and `evidence`; exact normalized SVG matches report `confidence: 1`. + +The default `minConfidence` is `0.99`, so unrelated or weakly similar SVGs return an empty list rather than a guessed SIDC. Pass a lower `minConfidence` when you want to inspect near matches or ambiguous alternatives. + +Reverse lookup v0 is deterministic clean-rendered-input comparison. Oversized SVG input is rejected before fuzzy scoring. It does not recognize screenshots, photos, scanned images, raster PNG/JPEG files, cropped symbols, hand-edited icons, map marker composites, or arbitrary MIL-STD-2525/APP-6 symbols outside the curated set. + ## Coverage -Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. +Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes common, verified examples across land units, air, sea surface, sea subsurface, land equipment, land installations, and control measures. Examples include infantry platoons and companies, fighter and rotary-wing aircraft, UAVs, destroyers, frigates, merchant ships, submarines, unmanned underwater vehicles, tanks, armored personnel carriers, bases, airports, checkpoints, and waypoints. + +Reverse lookup coverage is the same curated set and currently accepts clean SVG renderings only. + +Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`. Image-based reverse lookup is intentionally deferred. From 408e1f9a6fe9df70a7957bde56722809da9e9b84 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 04:40:56 -0400 Subject: [PATCH 16/23] fix: address integrated PR review findings --- README.md | 2 -- src/cli.ts | 4 +++- src/index.ts | 20 +++++++++++++++----- test/cli.test.mjs | 11 +++++++++++ test/sidc-kit.test.mjs | 22 +++++++++++++++++++++- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8238d08..4082e62 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,6 @@ Reverse lookup coverage is the same curated set and currently accepts clean SVG Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`. -Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`. - Image-based reverse lookup is intentionally deferred. ## Changelog diff --git a/src/cli.ts b/src/cli.ts index 522c0ba..4ecdecf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,7 +129,9 @@ function runExplain(args: readonly string[]): number { return 0; } - writeOutput(`${result.name}\n`); + if (result.coverage === "curated") { + writeOutput(`${result.name}\n`); + } writeOutput(`SIDC: ${result.sidc}\n`); writeOutput(`Coverage: ${result.coverage}\n`); writeOutput("Parts:\n"); diff --git a/src/index.ts b/src/index.ts index 9c412f5..3752366 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ export type BuildSidcInput = Partial & Partial( (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ - getFunctionId(symbol.sidc), + buildFunctionEntityKey(symbol.parts.symbolSet, getFunctionId(symbol.sidc)), { entity: symbol.parts.entity, ...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}), @@ -238,7 +239,7 @@ export function identifySymbol(input: string, options: IdentifySymbolOptions = { const similarity = svgSimilarity(normalizedInput, normalizedCandidate, threshold); const exact = normalizedInput === normalizedCandidate; - if (similarity < threshold) { + if (similarity === undefined || similarity < threshold) { return undefined; } @@ -457,7 +458,7 @@ function scorePartialFieldTerm(term: string, fields: readonly SearchField[], wei return fields.some((field) => field.text.includes(term)) ? weight : 0; } -function svgSimilarity(left: string, right: string, minSimilarity: number): number { +function svgSimilarity(left: string, right: string, minSimilarity: number): number | undefined { if (left === right) { return 1; } @@ -466,6 +467,9 @@ function svgSimilarity(left: string, right: string, minSimilarity: number): numb if (maxLength === 0) { return 1; } + if (maxLength > maxIdentifyFuzzySvgLength) { + return undefined; + } const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; if (lengthSimilarity < minSimilarity) { @@ -584,8 +588,10 @@ function createSupportedSymbol( function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { const domain = normalizeDimension(metadata.dimension); - const entityParts = functionEntityParts.get(metadata.functionid); const symbolSet = getSymbolSetLabel(domain, metadata); + const entityParts = symbolSet + ? functionEntityParts.get(buildFunctionEntityKey(symbolSet, metadata.functionid)) + : undefined; const affiliation = normalizeMetadataLabel(metadata.affiliation); const status = getStatusLabel(sidc.slice(6, 7), metadata); const echelon = normalizeMetadataLabel(metadata.echelon); @@ -619,7 +625,7 @@ function buildFields( knownCoverage: Exclude ): ExplainSidcFields { return { - affiliation: buildField(sidc.slice(2, 4), parts.affiliation, knownCoverage), + affiliation: buildField(sidc.slice(3, 4), parts.affiliation, knownCoverage), symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage), status: buildField(sidc.slice(6, 7), parts.status, knownCoverage), domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage), @@ -657,6 +663,10 @@ function getFunctionId(sidc: string): string { return sidc.slice(10, 20); } +function buildFunctionEntityKey(symbolSet: string, functionId: string): string { + return `${symbolSet}:${functionId}`; +} + function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined { if (!domain) { return undefined; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 3392f52..d4c7f1d 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(rootDir, "dist", "cli.js"); const infantryPlatoonSidc = "130310001412110000000000000000"; +const nonCuratedRenderableSidc = "130410001412110000000000000000"; function runCli(args) { return spawnSync(process.execPath, [cliPath, ...args], { @@ -64,6 +65,16 @@ test("CLI explains a curated SIDC as JSON", () => { assert.equal(parsed.parts.echelon, "platoon"); }); +test("CLI explains a partial SIDC without an undefined text heading", () => { + const result = runCli(["explain", nonCuratedRenderableSidc]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /undefined/); + assert.match(result.stdout, new RegExp(`^SIDC: ${nonCuratedRenderableSidc}\\nCoverage: partial\\nParts:\\n`)); + assert.match(result.stdout, / entity: infantry\n/); +}); + test("CLI builds a curated SIDC from structured options", () => { const result = runCli([ "build", diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 0926d86..534b820 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -31,6 +31,7 @@ const friendlyAirportSidc = "130320000012080300000000000000"; const friendlyCheckpointSidc = "130325000013030000000000000000"; const friendlyWaypointSidc = "130325000013180000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; +const neutralArtilleryPlatoonSidc = "130410001413030000000000000000"; const nonCuratedUnlabeledStatusSidc = "130410101412110000000000000000"; const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; @@ -174,12 +175,23 @@ test("explainSidc returns partial decomposition for non-curated renderable SIDCs assert.equal(result.parts.echelon, "platoon/detachment"); assert.deepEqual(result.unknownFields, []); assert.deepEqual(result.fields.affiliation, { - code: "04", + code: "4", value: "neutral", coverage: "known" }); }); +test("explainSidc scopes partial entity labels by symbol set", () => { + const result = explainSidc(neutralArtilleryPlatoonSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.symbolSet, "land unit"); + assert.equal(result.parts.entity, "artillery"); + assert.equal(result.parts.entityType, undefined); + assert.equal(result.fields.entity.code, "1303000000"); + assert.equal(result.fields.entity.coverage, "known"); +}); + test("explainSidc marks unlabeled non-present status unknown", () => { const result = explainSidc(nonCuratedUnlabeledStatusSidc); @@ -289,6 +301,14 @@ test("identifySymbol rejects oversized SVG input before fuzzy comparison", () => assert.deepEqual(identifySymbol(oversizedSvg, { minConfidence: 0 }), []); }); +test("identifySymbol skips expensive fuzzy scoring for large edited SVG input", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const largeEditedSvg = svg.replace("", `${"x".repeat(4_500)}`); + + assert.ok(largeEditedSvg.length < 10_000); + assert.deepEqual(identifySymbol(largeEditedSvg, { minConfidence: 0 }), []); +}); + test("searchSymbols weights exact names, aliases, and parts predictably", () => { assert.equal(searchSymbols("Friendly Land Unit Infantry Platoon")[0]?.sidc, infantryPlatoonSidc); assert.equal(searchSymbols("friend infantry company")[0]?.sidc, infantryCompanySidc); From 2124ebf2cdb5928e535a7ed568e6f08b2c23c69d Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 05:02:20 -0400 Subject: [PATCH 17/23] fix: address integrated review findings --- src/index.ts | 24 +++++++++++- test/sidc-kit.test.mjs | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3752366..30e0038 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ const sidcPattern = /^\d{30}$/; const defaultIdentifyMinConfidence = 0.99; const maxIdentifySvgInputLength = 10_000; const maxIdentifyFuzzySvgLength = 4_000; +const maxIdentifyFuzzyDistanceCells = 500_000; const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const; type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number]; type SearchFieldGroup = "names" | "aliases" | "parts"; @@ -139,8 +140,19 @@ const synonymGroups = [ ["inf", "infantry"] ] as const; type MilsymbolSymbol = InstanceType; +type CatalogSymbolSetLabels = Pick; type EntityParts = Pick & Partial>; +const catalogSymbolSetLabelsByCode = new Map( + (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ + getSymbolSetCode(symbol.sidc), + { + symbolSet: symbol.parts.symbolSet, + domain: symbol.parts.domain + } + ]) +); + const functionEntityParts = new Map( (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ buildFunctionEntityKey(symbol.parts.symbolSet, getFunctionId(symbol.sidc)), @@ -470,6 +482,9 @@ function svgSimilarity(left: string, right: string, minSimilarity: number): numb if (maxLength > maxIdentifyFuzzySvgLength) { return undefined; } + if (left.length * right.length > maxIdentifyFuzzyDistanceCells) { + return undefined; + } const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; if (lengthSimilarity < minSimilarity) { @@ -587,8 +602,9 @@ function createSupportedSymbol( } function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { - const domain = normalizeDimension(metadata.dimension); - const symbolSet = getSymbolSetLabel(domain, metadata); + const catalogLabels = catalogSymbolSetLabelsByCode.get(getSymbolSetCode(sidc)); + const domain = catalogLabels?.domain ?? normalizeDimension(metadata.dimension); + const symbolSet = catalogLabels?.symbolSet ?? getSymbolSetLabel(domain, metadata); const entityParts = symbolSet ? functionEntityParts.get(buildFunctionEntityKey(symbolSet, metadata.functionid)) : undefined; @@ -663,6 +679,10 @@ function getFunctionId(sidc: string): string { return sidc.slice(10, 20); } +function getSymbolSetCode(sidc: string): string { + return sidc.slice(4, 6); +} + function buildFunctionEntityKey(symbolSet: string, functionId: string): string { return `${symbolSet}:${functionId}`; } diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 534b820..872b15f 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -32,6 +32,12 @@ const friendlyCheckpointSidc = "130325000013030000000000000000"; const friendlyWaypointSidc = "130325000013180000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; const neutralArtilleryPlatoonSidc = "130410001413030000000000000000"; +const neutralAirFighterSidc = "130401000011010400000000000000"; +const neutralSeaDestroyerSidc = "130430000012020300000000000000"; +const neutralSubmarineSidc = "130435000011010000000000000000"; +const neutralTankSidc = "130415000012020000000000000000"; +const neutralBaseSidc = "130420000012080200000000000000"; +const neutralCheckpointSidc = "130425000013030000000000000000"; const nonCuratedUnlabeledStatusSidc = "130410101412110000000000000000"; const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; @@ -192,6 +198,76 @@ test("explainSidc scopes partial entity labels by symbol set", () => { assert.equal(result.fields.entity.coverage, "known"); }); +test("explainSidc reuses catalog symbol-set labels for non-curated expanded variants", () => { + const cases = [ + [ + neutralAirFighterSidc, + { + symbolSet: "air", + domain: "air", + entity: "aircraft", + entityType: "fighter" + } + ], + [ + neutralSeaDestroyerSidc, + { + symbolSet: "sea surface", + domain: "sea surface", + entity: "surface combatant", + entityType: "destroyer" + } + ], + [ + neutralSubmarineSidc, + { + symbolSet: "sea subsurface", + domain: "sea subsurface", + entity: "submarine" + } + ], + [ + neutralTankSidc, + { + symbolSet: "land equipment", + domain: "land equipment", + entity: "tank" + } + ], + [ + neutralBaseSidc, + { + symbolSet: "land installation", + domain: "land installation", + entity: "military infrastructure", + entityType: "base" + } + ], + [ + neutralCheckpointSidc, + { + symbolSet: "control measure", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + } + ] + ]; + + for (const [sidc, expected] of cases) { + const result = explainSidc(sidc); + + assert.equal(result.coverage, "partial", sidc); + assert.equal(result.parts.affiliation, "neutral", sidc); + assert.equal(result.parts.symbolSet, expected.symbolSet, sidc); + assert.equal(result.parts.domain, expected.domain, sidc); + assert.equal(result.parts.entity, expected.entity, sidc); + assert.equal(result.parts.entityType, expected.entityType, sidc); + assert.equal(result.fields.entity.coverage, "known", sidc); + assert.ok(!result.unknownFields.includes("entity"), sidc); + } +}); + test("explainSidc marks unlabeled non-present status unknown", () => { const result = explainSidc(nonCuratedUnlabeledStatusSidc); @@ -309,6 +385,13 @@ test("identifySymbol skips expensive fuzzy scoring for large edited SVG input", assert.deepEqual(identifySymbol(largeEditedSvg, { minConfidence: 0 }), []); }); +test("identifySymbol caps near-limit fuzzy SVG comparison work", () => { + const nearLimitSvg = `${"x".repeat(3_900)}`; + + assert.ok(nearLimitSvg.length < 4_000); + assert.deepEqual(identifySymbol(nearLimitSvg, { minConfidence: 0 }), []); +}); + test("searchSymbols weights exact names, aliases, and parts predictably", () => { assert.equal(searchSymbols("Friendly Land Unit Infantry Platoon")[0]?.sidc, infantryPlatoonSidc); assert.equal(searchSymbols("friend infantry company")[0]?.sidc, infantryCompanySidc); From 9707800709206b3c4da071766e82e0becae222f5 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 05:12:12 -0400 Subject: [PATCH 18/23] refactor: split public api implementation modules --- src/build.ts | 61 ++++ src/explain.ts | 197 ++++++++++++ src/identify.ts | 157 ++++++++++ src/index.ts | 792 ++--------------------------------------------- src/milsymbol.ts | 51 +++ src/render.ts | 64 ++++ src/search.ts | 154 +++++++++ src/sidc.ts | 41 +++ src/types.ts | 89 ++++++ 9 files changed, 838 insertions(+), 768 deletions(-) create mode 100644 src/build.ts create mode 100644 src/explain.ts create mode 100644 src/identify.ts create mode 100644 src/milsymbol.ts create mode 100644 src/render.ts create mode 100644 src/search.ts create mode 100644 src/sidc.ts create mode 100644 src/types.ts diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 0000000..5099678 --- /dev/null +++ b/src/build.ts @@ -0,0 +1,61 @@ +import { curatedSymbols, type CuratedSymbol } from "./data/symbols.js"; +import { SidcKitError } from "./errors.js"; +import { normalizeParts } from "./sidc.js"; +import type { BuildSidcInput } from "./types.js"; + +const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const; +type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number]; + +export function buildSidc(parts: BuildSidcInput): string { + const wanted = normalizeParts(parts); + const matches = curatedSymbols.filter((candidate) => { + const candidateParts = normalizeParts(candidate.parts); + return Object.entries(wanted).every(([key, value]) => candidateParts[key] === value); + }); + + if (matches.length === 0) { + throw new SidcKitError( + "UNSUPPORTED_COMBINATION", + `No curated SIDC matches affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}${ + parts.echelon ? `, echelon=${parts.echelon}` : "" + }.` + ); + } + + if (matches.length > 1) { + throw new SidcKitError( + "AMBIGUOUS_COMBINATION", + `Multiple curated SIDCs match affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}. ${buildAmbiguitySuggestion( + wanted, + matches + )}` + ); + } + + return matches[0].sidc; +} + +function buildAmbiguitySuggestion(wanted: Record, matches: readonly CuratedSymbol[]): string { + const helpfulKeys = disambiguatingPartKeys.filter((key) => !wanted[key] && hasVariation(matches, key)); + if (helpfulKeys.length === 0) { + return "The provided parts are still ambiguous."; + } + + return `Add ${formatPartList(helpfulKeys)}.`; +} + +function hasVariation(matches: readonly CuratedSymbol[], key: DisambiguatingPartKey): boolean { + return new Set(matches.map((match) => normalizeParts(match.parts)[key] ?? "")).size > 1; +} + +function formatPartList(parts: readonly string[]): string { + if (parts.length === 1) { + return parts[0]; + } + + if (parts.length === 2) { + return `${parts[0]} or ${parts[1]}`; + } + + return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`; +} diff --git a/src/explain.ts b/src/explain.ts new file mode 100644 index 0000000..7b40daf --- /dev/null +++ b/src/explain.ts @@ -0,0 +1,197 @@ +import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js"; +import { + createSupportedSymbol, + normalizeDimension, + normalizeMetadataLabel, + type SymbolMetadata +} from "./milsymbol.js"; +import { getFunctionId, getSymbolSetCode, normalizeSidc } from "./sidc.js"; +import type { + CuratedExplainSidcResult, + ExplainSidcFields, + ExplainSidcResult, + PartialExplainSidcResult, + PartialSymbolParts, + SidcField, + SidcFieldCoverage, + SidcFieldName +} from "./types.js"; + +type CatalogSymbolSetLabels = Pick; +type EntityParts = Pick & Partial>; + +const typedCuratedSymbols: readonly CuratedSymbol[] = curatedSymbols; + +const catalogSymbolSetLabelsByCode = new Map( + typedCuratedSymbols.map((symbol) => [ + getSymbolSetCode(symbol.sidc), + { + symbolSet: symbol.parts.symbolSet, + domain: symbol.parts.domain + } + ]) +); + +const functionEntityParts = new Map( + typedCuratedSymbols.map((symbol) => [ + buildFunctionEntityKey(symbol.parts.symbolSet, getFunctionId(symbol.sidc)), + { + entity: symbol.parts.entity, + ...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}), + ...(symbol.parts.entitySubtype ? { entitySubtype: symbol.parts.entitySubtype } : {}) + } + ]) +); + +export function explainSidc(sidc: string): ExplainSidcResult { + const normalizedSidc = normalizeSidc(sidc); + const symbol = findCuratedSidc(normalizedSidc); + if (symbol) { + return explainSymbol(symbol); + } + + const { metadata } = createSupportedSymbol(normalizedSidc, {}, "UNSUPPORTED_SIDC", "explain"); + return explainPartialSidc(normalizedSidc, metadata); +} + +export function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { + const fields = buildFields(symbol.sidc, symbol.parts, "curated"); + return { + sidc: symbol.sidc, + name: symbol.name, + aliases: [...symbol.aliases], + parts: { ...symbol.parts }, + coverage: "curated", + fields, + unknownFields: getUnknownFields(fields) + }; +} + +function findCuratedSidc(sidc: string): CuratedSymbol | undefined { + return curatedSymbols.find((candidate) => candidate.sidc === sidc); +} + +function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { + const parts = buildPartialParts(sidc, metadata); + const fields = buildFields(sidc, parts, "known"); + + return { + sidc, + aliases: [], + parts, + coverage: "partial", + fields, + unknownFields: getUnknownFields(fields) + }; +} + +function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { + const catalogLabels = catalogSymbolSetLabelsByCode.get(getSymbolSetCode(sidc)); + const domain = catalogLabels?.domain ?? normalizeDimension(metadata.dimension); + const symbolSet = catalogLabels?.symbolSet ?? getSymbolSetLabel(domain, metadata); + const entityParts = symbolSet + ? functionEntityParts.get(buildFunctionEntityKey(symbolSet, metadata.functionid)) + : undefined; + const affiliation = normalizeMetadataLabel(metadata.affiliation); + const status = getStatusLabel(sidc.slice(6, 7), metadata); + const echelon = normalizeMetadataLabel(metadata.echelon); + const parts: PartialSymbolParts = {}; + + if (symbolSet) { + parts.symbolSet = symbolSet; + } + if (affiliation) { + parts.affiliation = affiliation; + } + if (status) { + parts.status = status; + } + if (domain) { + parts.domain = domain; + } + if (entityParts) { + Object.assign(parts, entityParts); + } + if (echelon) { + parts.echelon = echelon; + } + + return parts; +} + +function buildFields( + sidc: string, + parts: PartialSymbolParts, + knownCoverage: Exclude +): ExplainSidcFields { + return { + affiliation: buildField(sidc.slice(3, 4), parts.affiliation, knownCoverage), + symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage), + status: buildField(sidc.slice(6, 7), parts.status, knownCoverage), + domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage), + echelon: buildField(sidc.slice(8, 10), parts.echelon, knownCoverage), + entity: buildField(getFunctionId(sidc), parts.entity, knownCoverage) + }; +} + +function buildField( + code: string, + value: string | undefined, + knownCoverage: Exclude +): SidcField { + if (!value) { + return { + code, + coverage: "unknown" + }; + } + + return { + code, + value, + coverage: knownCoverage + }; +} + +function getUnknownFields(fields: ExplainSidcFields): SidcFieldName[] { + return Object.entries(fields) + .filter(([, field]) => field.coverage === "unknown") + .map(([fieldName]) => fieldName as SidcFieldName); +} + +function buildFunctionEntityKey(symbolSet: string, functionId: string): string { + return `${symbolSet}:${functionId}`; +} + +function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined { + if (!domain) { + return undefined; + } + + if (metadata.unit === true) { + return `${domain} unit`; + } + + if (metadata.installation === true) { + return `${domain} installation`; + } + + if (metadata.activity === true) { + return `${domain} activity`; + } + + return undefined; +} + +function getStatusLabel(statusCode: string, metadata: SymbolMetadata): string | undefined { + const condition = normalizeMetadataLabel(metadata.condition); + if (condition) { + return condition; + } + + if (statusCode === "0") { + return "present"; + } + + return undefined; +} diff --git a/src/identify.ts b/src/identify.ts new file mode 100644 index 0000000..867b8fd --- /dev/null +++ b/src/identify.ts @@ -0,0 +1,157 @@ +import { curatedSymbols } from "./data/symbols.js"; +import { explainSymbol } from "./explain.js"; +import { renderSymbol } from "./render.js"; +import type { IdentifySymbolOptions, IdentifySymbolResult } from "./types.js"; + +const defaultIdentifyMinConfidence = 0.99; +const maxIdentifySvgInputLength = 10_000; +const maxIdentifyFuzzySvgLength = 4_000; +const maxIdentifyFuzzyDistanceCells = 500_000; + +export function identifySymbol(input: string, options: IdentifySymbolOptions = {}): IdentifySymbolResult[] { + const normalizedInput = normalizeSvgInput(input); + if (!normalizedInput) { + return []; + } + + const { limit = curatedSymbols.length, minConfidence = defaultIdentifyMinConfidence, ...renderOptions } = options; + const cappedLimit = Math.max(0, limit); + const threshold = clampConfidence(minConfidence); + + return curatedSymbols + .map((symbol) => { + const normalizedCandidate = normalizeSvgInput(renderSymbol(symbol.sidc, renderOptions).svg); + if (!normalizedCandidate) { + return undefined; + } + + const similarity = svgSimilarity(normalizedInput, normalizedCandidate, threshold); + const exact = normalizedInput === normalizedCandidate; + if (similarity === undefined || similarity < threshold) { + return undefined; + } + + const confidence = roundConfidence(similarity); + + return { + similarity, + result: { + ...explainSymbol(symbol), + confidence, + evidence: { + input: "svg" as const, + method: "normalized-svg" as const, + similarity: confidence, + exact, + notes: [ + exact + ? "Input SVG matches this curated milsymbol rendering after normalization." + : "Input SVG is only similar to this curated milsymbol rendering." + ] + } + } + }; + }) + .filter((candidate): candidate is { similarity: number; result: IdentifySymbolResult } => candidate !== undefined) + .sort((left, right) => right.similarity - left.similarity || left.result.name.localeCompare(right.result.name)) + .slice(0, cappedLimit) + .map((candidate) => candidate.result); +} + +function normalizeSvgInput(input: string): string | undefined { + const normalized = decodeInlineSvgDataUrl(input) + .replace(/^\uFEFF/, "") + .replace(/<\?xml[\s\S]*?\?>/gi, "") + .replace(//g, "") + .trim(); + + if (normalized.length > maxIdentifySvgInputLength) { + return undefined; + } + + if (!/^)/i.test(normalized)) { + return undefined; + } + + return normalized + .replace(/>\s+<") + .replace(/\s+\/>/g, "/>") + .replace(/\s+>/g, ">") + .replace(/\s*=\s*/g, "=") + .replace(/\s{2,}/g, " ") + .trim(); +} + +function decodeInlineSvgDataUrl(input: string): string { + const trimmed = input.trim(); + const dataUrl = /^data:image\/svg\+xml(?:;[^,]+)*,([\s\S]*)$/i.exec(trimmed); + if (!dataUrl) { + return trimmed; + } + + try { + return decodeURIComponent(dataUrl[1]); + } catch { + return dataUrl[1]; + } +} + +function svgSimilarity(left: string, right: string, minSimilarity: number): number | undefined { + if (left === right) { + return 1; + } + + const maxLength = Math.max(left.length, right.length); + if (maxLength === 0) { + return 1; + } + if (maxLength > maxIdentifyFuzzySvgLength) { + return undefined; + } + if (left.length * right.length > maxIdentifyFuzzyDistanceCells) { + return undefined; + } + + const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; + if (lengthSimilarity < minSimilarity) { + return lengthSimilarity; + } + + return 1 - levenshteinDistance(left, right) / maxLength; +} + +function levenshteinDistance(left: string, right: string): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + current[0] = leftIndex; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const cost = left.charCodeAt(leftIndex - 1) === right.charCodeAt(rightIndex - 1) ? 0 : 1; + current[rightIndex] = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + previous[rightIndex - 1] + cost + ); + } + + for (let index = 0; index <= right.length; index += 1) { + previous[index] = current[index]; + } + } + + return previous[right.length]; +} + +function clampConfidence(value: number): number { + if (Number.isNaN(value)) { + return defaultIdentifyMinConfidence; + } + + return Math.max(0, Math.min(1, value)); +} + +function roundConfidence(value: number): number { + return Number(clampConfidence(value).toFixed(6)); +} diff --git a/src/index.ts b/src/index.ts index 30e0038..ced055f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,770 +1,26 @@ -import ms, { type SymbolMetadata } from "milsymbol"; - -import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js"; -import { SidcKitError, type SidcKitErrorCode } from "./errors.js"; - +export { buildSidc } from "./build.js"; +export { explainSidc } from "./explain.js"; +export { identifySymbol } from "./identify.js"; +export { renderSymbol } from "./render.js"; +export { searchSymbols } from "./search.js"; export { SidcKitError } from "./errors.js"; export type { CuratedSymbol, SymbolParts } from "./data/symbols.js"; - -export type RenderSymbolOptions = { - size?: number; - fill?: boolean; - frame?: boolean; -}; - -export type RenderSymbolResult = { - sidc: string; - svg: string; - anchor?: { - x: number; - y: number; - }; - size?: { - width: number; - height: number; - }; -}; - -export type ExplainSidcCoverage = "curated" | "partial"; - -export type SidcFieldCoverage = "curated" | "known" | "unknown"; - -export type SidcField = { - code: string; - coverage: SidcFieldCoverage; - value?: string; -}; - -export type SidcFieldName = "affiliation" | "symbolSet" | "status" | "domain" | "echelon" | "entity"; - -export type ExplainSidcFields = Record; - -export type PartialSymbolParts = Partial; - -type BaseExplainSidcResult = { - sidc: string; - aliases: string[]; - fields: ExplainSidcFields; - unknownFields: SidcFieldName[]; -}; - -export type CuratedExplainSidcResult = BaseExplainSidcResult & { - name: string; - parts: SymbolParts; - coverage: "curated"; -}; - -export type PartialExplainSidcResult = BaseExplainSidcResult & { - name?: never; - parts: PartialSymbolParts; - coverage: "partial"; -}; - -export type ExplainSidcResult = CuratedExplainSidcResult | PartialExplainSidcResult; - -export type SymbolSearchOptions = { - limit?: number; -}; - -export type SymbolSearchResult = CuratedExplainSidcResult & { - score: number; -}; - -export type IdentifySymbolOptions = RenderSymbolOptions & { - limit?: number; - minConfidence?: number; -}; - -export type IdentifySymbolEvidence = { - input: "svg"; - method: "normalized-svg"; - similarity: number; - exact: boolean; - notes: string[]; -}; - -export type IdentifySymbolResult = CuratedExplainSidcResult & { - confidence: number; - evidence: IdentifySymbolEvidence; -}; - -export type BuildSidcInput = Partial> & { - affiliation: string; - domain: string; - entity: string; -}; - -const sidcPattern = /^\d{30}$/; -const defaultIdentifyMinConfidence = 0.99; -const maxIdentifySvgInputLength = 10_000; -const maxIdentifyFuzzySvgLength = 4_000; -const maxIdentifyFuzzyDistanceCells = 500_000; -const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const; -type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number]; -type SearchFieldGroup = "names" | "aliases" | "parts"; -type SearchField = { - text: string; - terms: Set; -}; -type SearchFields = Record; -type FieldScoreWeights = Record; - -const exactPhraseWeights = { - names: 100, - aliases: 90, - parts: 70 -} satisfies FieldScoreWeights; - -const exactTokenWeights = { - names: 12, - aliases: 10, - parts: 8 -} satisfies FieldScoreWeights; - -const relatedTokenWeights = { - names: 7, - aliases: 6, - parts: 5 -} satisfies FieldScoreWeights; - -const partialTokenWeights = { - names: 2, - aliases: 2, - parts: 1 -} satisfies FieldScoreWeights; - -const synonymGroups = [ - ["armor", "armour", "armored", "armoured", "tank"], - ["recon", "reconnaissance"], - ["arty", "artillery"], - ["inf", "infantry"] -] as const; -type MilsymbolSymbol = InstanceType; -type CatalogSymbolSetLabels = Pick; -type EntityParts = Pick & Partial>; - -const catalogSymbolSetLabelsByCode = new Map( - (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ - getSymbolSetCode(symbol.sidc), - { - symbolSet: symbol.parts.symbolSet, - domain: symbol.parts.domain - } - ]) -); - -const functionEntityParts = new Map( - (curatedSymbols as readonly CuratedSymbol[]).map((symbol) => [ - buildFunctionEntityKey(symbol.parts.symbolSet, getFunctionId(symbol.sidc)), - { - entity: symbol.parts.entity, - ...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}), - ...(symbol.parts.entitySubtype ? { entitySubtype: symbol.parts.entitySubtype } : {}) - } - ]) -); - -export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { - const normalizedSidc = normalizeSidc(sidc); - - try { - const { symbol } = createSupportedSymbol(normalizedSidc, options, "RENDER_FAILED", "render"); - - const svg = symbol.asSVG(); - const anchor = toPoint(symbol.getAnchor?.()); - const size = toSize(symbol.getSize?.()); - - return { - sidc: normalizedSidc, - svg, - ...(anchor ? { anchor } : {}), - ...(size ? { size } : {}) - }; - } catch (error) { - if (error instanceof SidcKitError) { - throw error; - } - - throw new SidcKitError( - "RENDER_FAILED", - `Failed to render SIDC ${normalizedSidc}: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -export function explainSidc(sidc: string): ExplainSidcResult { - const normalizedSidc = normalizeSidc(sidc); - const symbol = findCuratedSidc(normalizedSidc); - if (symbol) { - return explainSymbol(symbol); - } - - const { metadata } = createSupportedSymbol(normalizedSidc, {}, "UNSUPPORTED_SIDC", "explain"); - return explainPartialSidc(normalizedSidc, metadata); -} - -export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] { - const terms = tokenize(query); - if (terms.length === 0) { - return []; - } - - const limit = Math.max(0, options.limit ?? curatedSymbols.length); - if (limit === 0) { - return []; - } - const normalizedQuery = normalizeText(query); - - return curatedSymbols - .map((symbol, index) => { - const score = scoreSymbol(symbol, terms, normalizedQuery); - return { - index, - result: { - ...explainSymbol(symbol), - score - } - }; - }) - .filter(({ result }) => result.score > 0) - .sort((left, right) => right.result.score - left.result.score || left.index - right.index) - .map(({ result }) => result) - .slice(0, limit); -} - -export function identifySymbol(input: string, options: IdentifySymbolOptions = {}): IdentifySymbolResult[] { - const normalizedInput = normalizeSvgInput(input); - if (!normalizedInput) { - return []; - } - - const { limit = curatedSymbols.length, minConfidence = defaultIdentifyMinConfidence, ...renderOptions } = options; - const cappedLimit = Math.max(0, limit); - const threshold = clampConfidence(minConfidence); - - return curatedSymbols - .map((symbol) => { - const normalizedCandidate = normalizeSvgInput(renderSymbol(symbol.sidc, renderOptions).svg); - if (!normalizedCandidate) { - return undefined; - } - - const similarity = svgSimilarity(normalizedInput, normalizedCandidate, threshold); - const exact = normalizedInput === normalizedCandidate; - if (similarity === undefined || similarity < threshold) { - return undefined; - } - - const confidence = roundConfidence(similarity); - - return { - similarity, - result: { - ...explainSymbol(symbol), - confidence, - evidence: { - input: "svg" as const, - method: "normalized-svg" as const, - similarity: confidence, - exact, - notes: [ - exact - ? "Input SVG matches this curated milsymbol rendering after normalization." - : "Input SVG is only similar to this curated milsymbol rendering." - ] - } - } - }; - }) - .filter((candidate): candidate is { similarity: number; result: IdentifySymbolResult } => candidate !== undefined) - .sort((left, right) => right.similarity - left.similarity || left.result.name.localeCompare(right.result.name)) - .slice(0, cappedLimit) - .map((candidate) => candidate.result); -} - -export function buildSidc(parts: BuildSidcInput): string { - const wanted = normalizeParts(parts); - const matches = curatedSymbols.filter((candidate) => { - const candidateParts = normalizeParts(candidate.parts); - return Object.entries(wanted).every(([key, value]) => candidateParts[key] === value); - }); - - if (matches.length === 0) { - throw new SidcKitError( - "UNSUPPORTED_COMBINATION", - `No curated SIDC matches affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}${ - parts.echelon ? `, echelon=${parts.echelon}` : "" - }.` - ); - } - - if (matches.length > 1) { - throw new SidcKitError( - "AMBIGUOUS_COMBINATION", - `Multiple curated SIDCs match affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}. ${buildAmbiguitySuggestion( - wanted, - matches - )}` - ); - } - - return matches[0].sidc; -} - -function normalizeSidc(sidc: string): string { - const normalizedSidc = sidc.trim(); - if (!sidcPattern.test(normalizedSidc)) { - throw new SidcKitError("INVALID_SIDC", "SIDC must be exactly 30 digits."); - } - return normalizedSidc; -} - -function normalizeSvgInput(input: string): string | undefined { - const normalized = decodeInlineSvgDataUrl(input) - .replace(/^\uFEFF/, "") - .replace(/<\?xml[\s\S]*?\?>/gi, "") - .replace(//g, "") - .trim(); - - if (normalized.length > maxIdentifySvgInputLength) { - return undefined; - } - - if (!/^)/i.test(normalized)) { - return undefined; - } - - return normalized - .replace(/>\s+<") - .replace(/\s+\/>/g, "/>") - .replace(/\s+>/g, ">") - .replace(/\s*=\s*/g, "=") - .replace(/\s{2,}/g, " ") - .trim(); -} - -function decodeInlineSvgDataUrl(input: string): string { - const trimmed = input.trim(); - const dataUrl = /^data:image\/svg\+xml(?:;[^,]+)*,([\s\S]*)$/i.exec(trimmed); - if (!dataUrl) { - return trimmed; - } - - try { - return decodeURIComponent(dataUrl[1]); - } catch { - return dataUrl[1]; - } -} - -function findCuratedSidc(sidc: string): CuratedSymbol | undefined { - return curatedSymbols.find((candidate) => candidate.sidc === sidc); -} - -function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { - const fields = buildFields(symbol.sidc, symbol.parts, "curated"); - return { - sidc: symbol.sidc, - name: symbol.name, - aliases: [...symbol.aliases], - parts: { ...symbol.parts }, - coverage: "curated", - fields, - unknownFields: getUnknownFields(fields) - }; -} - -function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { - const parts = buildPartialParts(sidc, metadata); - const fields = buildFields(sidc, parts, "known"); - - return { - sidc, - aliases: [], - parts, - coverage: "partial", - fields, - unknownFields: getUnknownFields(fields) - }; -} - -function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[], normalizedQuery: string): number { - const fields = buildSearchFields(symbol); - - return ( - scoreExactPhrase(normalizedQuery, fields) + - queryTerms.reduce((score, term) => score + scoreQueryTerm(term, fields), 0) - ); -} - -function buildSearchFields(symbol: CuratedSymbol): SearchFields { - const partValues = Object.values(symbol.parts).filter((value): value is string => typeof value === "string"); - - return { - names: [toSearchField(symbol.name)], - aliases: symbol.aliases.map(toSearchField), - parts: partValues.map(toSearchField) - }; -} - -function toSearchField(value: string): SearchField { - return { - text: normalizeText(value), - terms: new Set(tokenize(value)) - }; -} - -function scoreExactPhrase(query: string, fields: SearchFields): number { - return scoreFieldPhrase(query, fields.names, exactPhraseWeights.names) + - scoreFieldPhrase(query, fields.aliases, exactPhraseWeights.aliases) + - scoreFieldPhrase(query, fields.parts, exactPhraseWeights.parts); -} - -function scoreFieldPhrase(query: string, fields: readonly SearchField[], weight: number): number { - return fields.some((field) => field.text === query) ? weight : 0; -} - -function scoreQueryTerm(term: string, fields: SearchFields): number { - const exactScore = scoreTerm(term, fields, exactTokenWeights); - const relatedScore = scoreRelatedTerms(term, fields); - const partialScore = exactScore > 0 ? 0 : scorePartialTerm(term, fields); - - return exactScore + relatedScore + partialScore; -} - -function scoreRelatedTerms(term: string, fields: SearchFields): number { - const relatedTerms = expandTerm(term).filter((candidate) => candidate !== term); - if (relatedTerms.length === 0) { - return 0; - } - - return Math.max(0, ...relatedTerms.map((relatedTerm) => scoreTerm(relatedTerm, fields, relatedTokenWeights))); -} - -function expandTerm(term: string): string[] { - const group = synonymGroups.find((terms) => (terms as readonly string[]).includes(term)); - return group ? [...group] : [term]; -} - -function scoreTerm(term: string, fields: SearchFields, weights: FieldScoreWeights): number { - return scoreFieldTerm(term, fields.names, weights.names) + - scoreFieldTerm(term, fields.aliases, weights.aliases) + - scoreFieldTerm(term, fields.parts, weights.parts); -} - -function scoreFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { - return fields.some((field) => field.terms.has(term)) ? weight : 0; -} - -function scorePartialTerm(term: string, fields: SearchFields): number { - if (term.length < 3) { - return 0; - } - - return scorePartialFieldTerm(term, fields.names, partialTokenWeights.names) + - scorePartialFieldTerm(term, fields.aliases, partialTokenWeights.aliases) + - scorePartialFieldTerm(term, fields.parts, partialTokenWeights.parts); -} - -function scorePartialFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { - return fields.some((field) => field.text.includes(term)) ? weight : 0; -} - -function svgSimilarity(left: string, right: string, minSimilarity: number): number | undefined { - if (left === right) { - return 1; - } - - const maxLength = Math.max(left.length, right.length); - if (maxLength === 0) { - return 1; - } - if (maxLength > maxIdentifyFuzzySvgLength) { - return undefined; - } - if (left.length * right.length > maxIdentifyFuzzyDistanceCells) { - return undefined; - } - - const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; - if (lengthSimilarity < minSimilarity) { - return lengthSimilarity; - } - - return 1 - levenshteinDistance(left, right) / maxLength; -} - -function levenshteinDistance(left: string, right: string): number { - const previous = Array.from({ length: right.length + 1 }, (_, index) => index); - const current = new Array(right.length + 1); - - for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { - current[0] = leftIndex; - - for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { - const cost = left.charCodeAt(leftIndex - 1) === right.charCodeAt(rightIndex - 1) ? 0 : 1; - current[rightIndex] = Math.min( - previous[rightIndex] + 1, - current[rightIndex - 1] + 1, - previous[rightIndex - 1] + cost - ); - } - - for (let index = 0; index <= right.length; index += 1) { - previous[index] = current[index]; - } - } - - return previous[right.length]; -} - -function clampConfidence(value: number): number { - if (Number.isNaN(value)) { - return defaultIdentifyMinConfidence; - } - - return Math.max(0, Math.min(1, value)); -} - -function roundConfidence(value: number): number { - return Number(clampConfidence(value).toFixed(6)); -} - -function tokenize(value: string): string[] { - return normalizeText(value) - .trim() - .split(/\s+/) - .filter(Boolean); -} - -function normalizeText(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); -} - -function normalizeParts(parts: BuildSidcInput | SymbolParts): Record { - return Object.entries(parts).reduce>((normalized, [key, value]) => { - if (typeof value === "string") { - normalized[key] = value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - } - return normalized; - }, {}); -} - -function buildAmbiguitySuggestion(wanted: Record, matches: readonly CuratedSymbol[]): string { - const helpfulKeys = disambiguatingPartKeys.filter((key) => !wanted[key] && hasVariation(matches, key)); - if (helpfulKeys.length === 0) { - return "The provided parts are still ambiguous."; - } - - return `Add ${formatPartList(helpfulKeys)}.`; -} - -function hasVariation(matches: readonly CuratedSymbol[], key: DisambiguatingPartKey): boolean { - return new Set(matches.map((match) => normalizeParts(match.parts)[key] ?? "")).size > 1; -} - -function formatPartList(parts: readonly string[]): string { - if (parts.length === 1) { - return parts[0]; - } - - if (parts.length === 2) { - return `${parts[0]} or ${parts[1]}`; - } - - return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`; -} - -function createSupportedSymbol( - sidc: string, - options: RenderSymbolOptions, - failureCode: SidcKitErrorCode, - action: "explain" | "render" -): { symbol: MilsymbolSymbol; metadata: SymbolMetadata } { - try { - const symbol = new ms.Symbol(sidc, options); - const metadata = symbol.getMetadata(); - if (symbol.isValid() !== true || metadata.dimensionUnknown) { - throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`); - } - - return { symbol, metadata }; - } catch (error) { - if (error instanceof SidcKitError) { - throw error; - } - - throw new SidcKitError( - failureCode, - `Failed to ${action} SIDC ${sidc}: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { - const catalogLabels = catalogSymbolSetLabelsByCode.get(getSymbolSetCode(sidc)); - const domain = catalogLabels?.domain ?? normalizeDimension(metadata.dimension); - const symbolSet = catalogLabels?.symbolSet ?? getSymbolSetLabel(domain, metadata); - const entityParts = symbolSet - ? functionEntityParts.get(buildFunctionEntityKey(symbolSet, metadata.functionid)) - : undefined; - const affiliation = normalizeMetadataLabel(metadata.affiliation); - const status = getStatusLabel(sidc.slice(6, 7), metadata); - const echelon = normalizeMetadataLabel(metadata.echelon); - const parts: PartialSymbolParts = {}; - - if (symbolSet) { - parts.symbolSet = symbolSet; - } - if (affiliation) { - parts.affiliation = affiliation; - } - if (status) { - parts.status = status; - } - if (domain) { - parts.domain = domain; - } - if (entityParts) { - Object.assign(parts, entityParts); - } - if (echelon) { - parts.echelon = echelon; - } - - return parts; -} - -function buildFields( - sidc: string, - parts: PartialSymbolParts, - knownCoverage: Exclude -): ExplainSidcFields { - return { - affiliation: buildField(sidc.slice(3, 4), parts.affiliation, knownCoverage), - symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage), - status: buildField(sidc.slice(6, 7), parts.status, knownCoverage), - domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage), - echelon: buildField(sidc.slice(8, 10), parts.echelon, knownCoverage), - entity: buildField(getFunctionId(sidc), parts.entity, knownCoverage) - }; -} - -function buildField( - code: string, - value: string | undefined, - knownCoverage: Exclude -): SidcField { - if (!value) { - return { - code, - coverage: "unknown" - }; - } - - return { - code, - value, - coverage: knownCoverage - }; -} - -function getUnknownFields(fields: ExplainSidcFields): SidcFieldName[] { - return Object.entries(fields) - .filter(([, field]) => field.coverage === "unknown") - .map(([fieldName]) => fieldName as SidcFieldName); -} - -function getFunctionId(sidc: string): string { - return sidc.slice(10, 20); -} - -function getSymbolSetCode(sidc: string): string { - return sidc.slice(4, 6); -} - -function buildFunctionEntityKey(symbolSet: string, functionId: string): string { - return `${symbolSet}:${functionId}`; -} - -function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined { - if (!domain) { - return undefined; - } - - if (metadata.unit === true) { - return `${domain} unit`; - } - - if (metadata.installation === true) { - return `${domain} installation`; - } - - if (metadata.activity === true) { - return `${domain} activity`; - } - - return undefined; -} - -function getStatusLabel(statusCode: string, metadata: SymbolMetadata): string | undefined { - const condition = normalizeMetadataLabel(metadata.condition); - if (condition) { - return condition; - } - - if (statusCode === "0") { - return "present"; - } - - return undefined; -} - -function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined { - if (value === "Ground") { - return "land"; - } - - return normalizeMetadataLabel(value); -} - -function normalizeMetadataLabel(value: string | undefined): string | undefined { - const normalized = value?.trim(); - if (!normalized || normalized === "undefined") { - return undefined; - } - - return normalized.toLowerCase(); -} - -function toPoint(value: unknown): RenderSymbolResult["anchor"] { - if (!isObject(value)) { - return undefined; - } - - const x = value.x; - const y = value.y; - if (typeof x === "number" && typeof y === "number") { - return { x, y }; - } - - return undefined; -} - -function toSize(value: unknown): RenderSymbolResult["size"] { - if (!isObject(value)) { - return undefined; - } - - const width = value.width; - const height = value.height; - if (typeof width === "number" && typeof height === "number") { - return { width, height }; - } - - return undefined; -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} +export type { + BuildSidcInput, + CuratedExplainSidcResult, + ExplainSidcCoverage, + ExplainSidcFields, + ExplainSidcResult, + IdentifySymbolEvidence, + IdentifySymbolOptions, + IdentifySymbolResult, + PartialExplainSidcResult, + PartialSymbolParts, + RenderSymbolOptions, + RenderSymbolResult, + SidcField, + SidcFieldCoverage, + SidcFieldName, + SymbolSearchOptions, + SymbolSearchResult +} from "./types.js"; diff --git a/src/milsymbol.ts b/src/milsymbol.ts new file mode 100644 index 0000000..3adc55a --- /dev/null +++ b/src/milsymbol.ts @@ -0,0 +1,51 @@ +import ms, { type SymbolMetadata } from "milsymbol"; + +import { SidcKitError, type SidcKitErrorCode } from "./errors.js"; +import type { RenderSymbolOptions } from "./types.js"; + +export type { SymbolMetadata } from "milsymbol"; + +export type MilsymbolSymbol = InstanceType; + +export function createSupportedSymbol( + sidc: string, + options: RenderSymbolOptions, + failureCode: SidcKitErrorCode, + action: "explain" | "render" +): { symbol: MilsymbolSymbol; metadata: SymbolMetadata } { + try { + const symbol = new ms.Symbol(sidc, options); + const metadata = symbol.getMetadata(); + if (symbol.isValid() !== true || metadata.dimensionUnknown) { + throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`); + } + + return { symbol, metadata }; + } catch (error) { + if (error instanceof SidcKitError) { + throw error; + } + + throw new SidcKitError( + failureCode, + `Failed to ${action} SIDC ${sidc}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined { + if (value === "Ground") { + return "land"; + } + + return normalizeMetadataLabel(value); +} + +export function normalizeMetadataLabel(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized || normalized === "undefined") { + return undefined; + } + + return normalized.toLowerCase(); +} diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..d57c74f --- /dev/null +++ b/src/render.ts @@ -0,0 +1,64 @@ +import { SidcKitError } from "./errors.js"; +import { createSupportedSymbol } from "./milsymbol.js"; +import { normalizeSidc } from "./sidc.js"; +import type { RenderSymbolOptions, RenderSymbolResult } from "./types.js"; + +export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { + const normalizedSidc = normalizeSidc(sidc); + + try { + const { symbol } = createSupportedSymbol(normalizedSidc, options, "RENDER_FAILED", "render"); + + const svg = symbol.asSVG(); + const anchor = toPoint(symbol.getAnchor?.()); + const size = toSize(symbol.getSize?.()); + + return { + sidc: normalizedSidc, + svg, + ...(anchor ? { anchor } : {}), + ...(size ? { size } : {}) + }; + } catch (error) { + if (error instanceof SidcKitError) { + throw error; + } + + throw new SidcKitError( + "RENDER_FAILED", + `Failed to render SIDC ${normalizedSidc}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function toPoint(value: unknown): RenderSymbolResult["anchor"] { + if (!isObject(value)) { + return undefined; + } + + const x = value.x; + const y = value.y; + if (typeof x === "number" && typeof y === "number") { + return { x, y }; + } + + return undefined; +} + +function toSize(value: unknown): RenderSymbolResult["size"] { + if (!isObject(value)) { + return undefined; + } + + const width = value.width; + const height = value.height; + if (typeof width === "number" && typeof height === "number") { + return { width, height }; + } + + return undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..ea409d6 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,154 @@ +import { curatedSymbols, type CuratedSymbol } from "./data/symbols.js"; +import { explainSymbol } from "./explain.js"; +import { normalizeText, tokenize } from "./sidc.js"; +import type { SymbolSearchOptions, SymbolSearchResult } from "./types.js"; + +type SearchFieldGroup = "names" | "aliases" | "parts"; +type SearchField = { + text: string; + terms: Set; +}; +type SearchFields = Record; +type FieldScoreWeights = Record; + +const exactPhraseWeights = { + names: 100, + aliases: 90, + parts: 70 +} satisfies FieldScoreWeights; + +const exactTokenWeights = { + names: 12, + aliases: 10, + parts: 8 +} satisfies FieldScoreWeights; + +const relatedTokenWeights = { + names: 7, + aliases: 6, + parts: 5 +} satisfies FieldScoreWeights; + +const partialTokenWeights = { + names: 2, + aliases: 2, + parts: 1 +} satisfies FieldScoreWeights; + +const synonymGroups = [ + ["armor", "armour", "armored", "armoured", "tank"], + ["recon", "reconnaissance"], + ["arty", "artillery"], + ["inf", "infantry"] +] as const; + +export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] { + const terms = tokenize(query); + if (terms.length === 0) { + return []; + } + + const limit = Math.max(0, options.limit ?? curatedSymbols.length); + if (limit === 0) { + return []; + } + const normalizedQuery = normalizeText(query); + + return curatedSymbols + .map((symbol, index) => { + const score = scoreSymbol(symbol, terms, normalizedQuery); + return { + index, + result: { + ...explainSymbol(symbol), + score + } + }; + }) + .filter(({ result }) => result.score > 0) + .sort((left, right) => right.result.score - left.result.score || left.index - right.index) + .map(({ result }) => result) + .slice(0, limit); +} + +function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[], normalizedQuery: string): number { + const fields = buildSearchFields(symbol); + + return ( + scoreExactPhrase(normalizedQuery, fields) + + queryTerms.reduce((score, term) => score + scoreQueryTerm(term, fields), 0) + ); +} + +function buildSearchFields(symbol: CuratedSymbol): SearchFields { + const partValues = Object.values(symbol.parts).filter((value): value is string => typeof value === "string"); + + return { + names: [toSearchField(symbol.name)], + aliases: symbol.aliases.map(toSearchField), + parts: partValues.map(toSearchField) + }; +} + +function toSearchField(value: string): SearchField { + return { + text: normalizeText(value), + terms: new Set(tokenize(value)) + }; +} + +function scoreExactPhrase(query: string, fields: SearchFields): number { + return scoreFieldPhrase(query, fields.names, exactPhraseWeights.names) + + scoreFieldPhrase(query, fields.aliases, exactPhraseWeights.aliases) + + scoreFieldPhrase(query, fields.parts, exactPhraseWeights.parts); +} + +function scoreFieldPhrase(query: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text === query) ? weight : 0; +} + +function scoreQueryTerm(term: string, fields: SearchFields): number { + const exactScore = scoreTerm(term, fields, exactTokenWeights); + const relatedScore = scoreRelatedTerms(term, fields); + const partialScore = exactScore > 0 ? 0 : scorePartialTerm(term, fields); + + return exactScore + relatedScore + partialScore; +} + +function scoreRelatedTerms(term: string, fields: SearchFields): number { + const relatedTerms = expandTerm(term).filter((candidate) => candidate !== term); + if (relatedTerms.length === 0) { + return 0; + } + + return Math.max(0, ...relatedTerms.map((relatedTerm) => scoreTerm(relatedTerm, fields, relatedTokenWeights))); +} + +function expandTerm(term: string): string[] { + const group = synonymGroups.find((terms) => (terms as readonly string[]).includes(term)); + return group ? [...group] : [term]; +} + +function scoreTerm(term: string, fields: SearchFields, weights: FieldScoreWeights): number { + return scoreFieldTerm(term, fields.names, weights.names) + + scoreFieldTerm(term, fields.aliases, weights.aliases) + + scoreFieldTerm(term, fields.parts, weights.parts); +} + +function scoreFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.terms.has(term)) ? weight : 0; +} + +function scorePartialTerm(term: string, fields: SearchFields): number { + if (term.length < 3) { + return 0; + } + + return scorePartialFieldTerm(term, fields.names, partialTokenWeights.names) + + scorePartialFieldTerm(term, fields.aliases, partialTokenWeights.aliases) + + scorePartialFieldTerm(term, fields.parts, partialTokenWeights.parts); +} + +function scorePartialFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text.includes(term)) ? weight : 0; +} diff --git a/src/sidc.ts b/src/sidc.ts new file mode 100644 index 0000000..d9f3287 --- /dev/null +++ b/src/sidc.ts @@ -0,0 +1,41 @@ +import type { SymbolParts } from "./data/symbols.js"; +import { SidcKitError } from "./errors.js"; +import type { BuildSidcInput } from "./types.js"; + +const sidcPattern = /^\d{30}$/; + +export function normalizeSidc(sidc: string): string { + const normalizedSidc = sidc.trim(); + if (!sidcPattern.test(normalizedSidc)) { + throw new SidcKitError("INVALID_SIDC", "SIDC must be exactly 30 digits."); + } + return normalizedSidc; +} + +export function getFunctionId(sidc: string): string { + return sidc.slice(10, 20); +} + +export function getSymbolSetCode(sidc: string): string { + return sidc.slice(4, 6); +} + +export function tokenize(value: string): string[] { + return normalizeText(value) + .trim() + .split(/\s+/) + .filter(Boolean); +} + +export function normalizeText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +export function normalizeParts(parts: BuildSidcInput | SymbolParts): Record { + return Object.entries(parts).reduce>((normalized, [key, value]) => { + if (typeof value === "string") { + normalized[key] = normalizeText(value); + } + return normalized; + }, {}); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3070be9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,89 @@ +import type { SymbolParts } from "./data/symbols.js"; + +export type RenderSymbolOptions = { + size?: number; + fill?: boolean; + frame?: boolean; +}; + +export type RenderSymbolResult = { + sidc: string; + svg: string; + anchor?: { + x: number; + y: number; + }; + size?: { + width: number; + height: number; + }; +}; + +export type ExplainSidcCoverage = "curated" | "partial"; + +export type SidcFieldCoverage = "curated" | "known" | "unknown"; + +export type SidcField = { + code: string; + coverage: SidcFieldCoverage; + value?: string; +}; + +export type SidcFieldName = "affiliation" | "symbolSet" | "status" | "domain" | "echelon" | "entity"; + +export type ExplainSidcFields = Record; + +export type PartialSymbolParts = Partial; + +type BaseExplainSidcResult = { + sidc: string; + aliases: string[]; + fields: ExplainSidcFields; + unknownFields: SidcFieldName[]; +}; + +export type CuratedExplainSidcResult = BaseExplainSidcResult & { + name: string; + parts: SymbolParts; + coverage: "curated"; +}; + +export type PartialExplainSidcResult = BaseExplainSidcResult & { + name?: never; + parts: PartialSymbolParts; + coverage: "partial"; +}; + +export type ExplainSidcResult = CuratedExplainSidcResult | PartialExplainSidcResult; + +export type SymbolSearchOptions = { + limit?: number; +}; + +export type SymbolSearchResult = CuratedExplainSidcResult & { + score: number; +}; + +export type IdentifySymbolOptions = RenderSymbolOptions & { + limit?: number; + minConfidence?: number; +}; + +export type IdentifySymbolEvidence = { + input: "svg"; + method: "normalized-svg"; + similarity: number; + exact: boolean; + notes: string[]; +}; + +export type IdentifySymbolResult = CuratedExplainSidcResult & { + confidence: number; + evidence: IdentifySymbolEvidence; +}; + +export type BuildSidcInput = Partial> & { + affiliation: string; + domain: string; + entity: string; +}; From e82de3853b532d366f30380d30aa13c7cf4670e3 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 05:22:00 -0400 Subject: [PATCH 19/23] chore: address opus review follow-ups --- package.json | 1 + src/explain.ts | 6 +++++- test/sidc-kit.test.mjs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index aa007af..47b4454 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.3.2", "description": "A small TypeScript toolkit for rendering SIDCs and working with curated SIDC semantics.", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "browser": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/explain.ts b/src/explain.ts index 7b40daf..2dfba82 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -22,6 +22,10 @@ type EntityParts = Pick & Partial( + typedCuratedSymbols.map((symbol) => [symbol.sidc, symbol]) +); + const catalogSymbolSetLabelsByCode = new Map( typedCuratedSymbols.map((symbol) => [ getSymbolSetCode(symbol.sidc), @@ -68,7 +72,7 @@ export function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { } function findCuratedSidc(sidc: string): CuratedSymbol | undefined { - return curatedSymbols.find((candidate) => candidate.sidc === sidc); + return curatedSymbolsBySidc.get(sidc); } function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 872b15f..b29b871 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -9,6 +9,7 @@ import { renderSymbol, searchSymbols } from "../dist/index.js"; +import * as sidcKit from "../dist/index.js"; import { curatedSymbols } from "../dist/data/catalog/index.js"; const infantryPlatoonSidc = "130310001412110000000000000000"; @@ -76,6 +77,17 @@ function buildInputFor(parts) { return input; } +test("public runtime facade exposes the intended API", () => { + assert.deepEqual(Object.keys(sidcKit).sort(), [ + "SidcKitError", + "buildSidc", + "explainSidc", + "identifySymbol", + "renderSymbol", + "searchSymbols" + ]); +}); + test("renderSymbol returns SVG for a known SIDC", () => { const result = renderSymbol(infantryPlatoonSidc, { size: 40 }); From f436c28d2eedace0d3d3ba45691969185d1a37dc Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 05:30:06 -0400 Subject: [PATCH 20/23] chore: trim unused declaration maps --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 5ea7fff..429ccbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "declaration": true, - "declarationMap": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "lib": ["ES2022"], From 01e2ebecbabe54ad2cb0cfbe03feffe694aabcd1 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 06:05:25 -0400 Subject: [PATCH 21/23] fix: guard identify candidate rendering --- src/identify.ts | 12 ++++++++++++ test/sidc-kit.test.mjs | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/identify.ts b/src/identify.ts index 867b8fd..0526a3a 100644 --- a/src/identify.ts +++ b/src/identify.ts @@ -7,6 +7,7 @@ const defaultIdentifyMinConfidence = 0.99; const maxIdentifySvgInputLength = 10_000; const maxIdentifyFuzzySvgLength = 4_000; const maxIdentifyFuzzyDistanceCells = 500_000; +const maxIdentifyCandidateRenderSize = 4096; export function identifySymbol(input: string, options: IdentifySymbolOptions = {}): IdentifySymbolResult[] { const normalizedInput = normalizeSvgInput(input); @@ -17,6 +18,12 @@ export function identifySymbol(input: string, options: IdentifySymbolOptions = { const { limit = curatedSymbols.length, minConfidence = defaultIdentifyMinConfidence, ...renderOptions } = options; const cappedLimit = Math.max(0, limit); const threshold = clampConfidence(minConfidence); + if (cappedLimit === 0) { + return []; + } + if (!hasSafeCandidateRenderOptions(renderOptions)) { + return []; + } return curatedSymbols .map((symbol) => { @@ -58,6 +65,11 @@ export function identifySymbol(input: string, options: IdentifySymbolOptions = { .map((candidate) => candidate.result); } +function hasSafeCandidateRenderOptions(options: Pick): boolean { + return options.size === undefined || + (Number.isFinite(options.size) && options.size > 0 && options.size <= maxIdentifyCandidateRenderSize); +} + function normalizeSvgInput(input: string): string | undefined { const normalized = decodeInlineSvgDataUrl(input) .replace(/^\uFEFF/, "") diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index b29b871..b0eacb4 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -404,6 +404,20 @@ test("identifySymbol caps near-limit fuzzy SVG comparison work", () => { assert.deepEqual(identifySymbol(nearLimitSvg, { minConfidence: 0 }), []); }); +test("identifySymbol returns no candidates when limit is zero", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + + assert.deepEqual(identifySymbol(svg, { limit: 0, minConfidence: 0 }), []); +}); + +test("identifySymbol rejects unsafe candidate render sizes before catalog matching", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + + assert.deepEqual(identifySymbol(svg, { size: 4097, minConfidence: 0 }), []); + assert.deepEqual(identifySymbol(svg, { size: Number.POSITIVE_INFINITY, minConfidence: 0 }), []); + assert.deepEqual(identifySymbol(svg, { size: 0, minConfidence: 0 }), []); +}); + test("searchSymbols weights exact names, aliases, and parts predictably", () => { assert.equal(searchSymbols("Friendly Land Unit Infantry Platoon")[0]?.sidc, infantryPlatoonSidc); assert.equal(searchSymbols("friend infantry company")[0]?.sidc, infantryCompanySidc); From 9902157eb3f98429c4949a733703b295db647a3e Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 13:43:54 -0400 Subject: [PATCH 22/23] fix: support base64 svg data urls --- src/identify.ts | 34 +++++++++++++++++++++++++++++++--- test/sidc-kit.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/identify.ts b/src/identify.ts index 0526a3a..74157c6 100644 --- a/src/identify.ts +++ b/src/identify.ts @@ -96,15 +96,43 @@ function normalizeSvgInput(input: string): string | undefined { function decodeInlineSvgDataUrl(input: string): string { const trimmed = input.trim(); - const dataUrl = /^data:image\/svg\+xml(?:;[^,]+)*,([\s\S]*)$/i.exec(trimmed); + const dataUrl = /^data:image\/svg\+xml((?:;[^,]+)*),([\s\S]*)$/i.exec(trimmed); if (!dataUrl) { return trimmed; } + const metadata = dataUrl[1]; + const payload = dataUrl[2]; + if (/(?:^|;)base64(?:;|$)/i.test(metadata)) { + return decodeBase64Utf8(payload) ?? payload; + } + + try { + return decodeURIComponent(payload); + } catch { + return payload; + } +} + +function decodeBase64Utf8(payload: string): string | undefined { + if (typeof globalThis.atob !== "function") { + return undefined; + } + + try { + const binary = globalThis.atob(decodeUriComponentOrOriginal(payload).replace(/\s/g, "")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return undefined; + } +} + +function decodeUriComponentOrOriginal(value: string): string { try { - return decodeURIComponent(dataUrl[1]); + return decodeURIComponent(value); } catch { - return dataUrl[1]; + return value; } } diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index b0eacb4..1187353 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -44,6 +44,17 @@ const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; +function encodeBase64Utf8(value) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); +} + const expandedCatalogSidcs = [ friendlyAirFighterSidc, friendlyAirRotaryWingSidc, @@ -351,6 +362,18 @@ test("identifySymbol normalizes near-exact SVG input", () => { assert.equal(results[0].evidence.exact, true); }); +test("identifySymbol normalizes base64 SVG data URLs", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const exportedSvg = `\n${svg}`; + const dataUrlSvg = `data:image/svg+xml;charset=utf-8;base64,${encodeBase64Utf8(exportedSvg)}`; + const results = identifySymbol(dataUrlSvg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); +}); + test("identifySymbol returns stable ranked candidates when a broad threshold is requested", () => { const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; const results = identifySymbol(svg, { size: 40, minConfidence: 0.93, limit: 4 }); From c09bfcac04fee14dc8debdfb1479150fe510568e Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Fri, 26 Jun 2026 13:45:19 -0400 Subject: [PATCH 23/23] fix: clarify ambiguous build suggestions --- src/build.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/build.ts b/src/build.ts index 5099678..f7ef925 100644 --- a/src/build.ts +++ b/src/build.ts @@ -41,21 +41,13 @@ function buildAmbiguitySuggestion(wanted: Record, matches: reado return "The provided parts are still ambiguous."; } - return `Add ${formatPartList(helpfulKeys)}.`; + if (helpfulKeys.length === 1) { + return `Add ${helpfulKeys[0]}.`; + } + + return `Add enough distinguishing parts from: ${helpfulKeys.join(", ")}.`; } function hasVariation(matches: readonly CuratedSymbol[], key: DisambiguatingPartKey): boolean { return new Set(matches.map((match) => normalizeParts(match.parts)[key] ?? "")).size > 1; } - -function formatPartList(parts: readonly string[]): string { - if (parts.length === 1) { - return parts[0]; - } - - if (parts.length === 2) { - return `${parts[0]} or ${parts[1]}`; - } - - return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`; -}