From 54dbdd79f2a4d2bbb907c13e9f4f4313c8d7bed2 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 01:08:22 -0400 Subject: [PATCH 1/3] 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 a307b1bc9a1b9a9411a1a9d8aa37db2889d70177 Mon Sep 17 00:00:00 2001 From: the-Drunken-coder Date: Thu, 25 Jun 2026 15:49:24 -0400 Subject: [PATCH 2/3] 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 3/3] 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; }