Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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");

Expand All @@ -49,6 +50,8 @@ const sidc = buildSidc({
});

const rendered = renderSymbol(sidc, { size: 40 });

const matches = identifySymbol(rendered.svg, { size: 40 });
```

## API
Expand All @@ -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. 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.

Image-based reverse lookup is intentionally deferred.
Reverse lookup coverage is the same curated set and currently accepts clean SVG renderings only.

## Changelog

Expand Down
162 changes: 162 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,33 @@ 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<Omit<SymbolParts, "standard" | "status" | "symbolSet">> & {
affiliation: string;
domain: string;
entity: string;
};

const sidcPattern = /^\d{30}$/;
const defaultIdentifyMinConfidence = 0.99;
const maxIdentifySvgInputLength = 10_000;
const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const;
type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number];

Expand Down Expand Up @@ -110,6 +130,56 @@ export function searchSymbols(query: string, options: SymbolSearchOptions = {}):
.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 < threshold) {
return undefined;
}

const confidence = roundConfidence(similarity);
Comment thread
the-Drunken-coder marked this conversation as resolved.

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) => {
Expand Down Expand Up @@ -147,6 +217,44 @@ 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(/<!--[\s\S]*?-->/g, "")
.trim();

if (normalized.length > maxIdentifySvgInputLength) {
return undefined;
}

if (!/^<svg(?:\s|>)/i.test(normalized)) {
return undefined;
}

return normalized
.replace(/>\s+</g, "><")
.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 requireCuratedSidc(sidc: string): CuratedSymbol {
const symbol = curatedSymbols.find((candidate) => candidate.sidc === sidc);
if (!symbol) {
Expand Down Expand Up @@ -185,6 +293,60 @@ function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[]): numb
}, 0);
}

function svgSimilarity(left: string, right: string, minSimilarity: number): number {
if (left === right) {
return 1;
}

const maxLength = Math.max(left.length, right.length);
if (maxLength === 0) {
return 1;
}

Comment thread
the-Drunken-coder marked this conversation as resolved.
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<number>(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 value
.toLowerCase()
Expand Down
62 changes: 62 additions & 0 deletions test/sidc-kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SidcKitError,
buildSidc,
explainSidc,
identifySymbol,
renderSymbol,
searchSymbols
} from "../dist/index.js";
Expand Down Expand Up @@ -74,6 +75,67 @@ 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<!-- exported from a clean renderer -->\n${svg.replaceAll("><", ">\n <")}\n`;
const dataUrlSvg = `data:image/svg+xml;utf8,${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 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('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="8"/></svg>');

assert.deepEqual(results, []);
});

test("identifySymbol rejects oversized SVG input before fuzzy comparison", () => {
const oversizedSvg = `<svg>${"x".repeat(10_001)}</svg>`;

assert.deepEqual(identifySymbol(oversizedSvg, { minConfidence: 0 }), []);
});

test("buildSidc creates the expected known SIDC from structured parts", () => {
assert.equal(
buildSidc({
Expand Down
Loading