diff --git a/README.md b/README.md index 4cb14d3..e2cb224 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. 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/index.ts b/src/index.ts index 8d9ffdf..7614f9c 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(sidc, metadata); + const fields = buildFields(sidc, parts, "known"); + + return { + sidc, + aliases: [], + parts, + coverage: "partial", + fields, + unknownFields: getUnknownFields(fields) }; } @@ -228,6 +284,157 @@ 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.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 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 = {}; + + 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(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(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; diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..5a4d8db 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -15,6 +15,8 @@ const armorPlatoonSidc = "130310001412050000000000000000"; const artilleryPlatoonSidc = "130310001413030000000000000000"; const reconnaissancePlatoonSidc = "130310001412130000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; +const nonCuratedUnlabeledStatusSidc = "130410101412110000000000000000"; +const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; @@ -57,15 +59,64 @@ 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 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(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");