diff --git a/CLAUDE.md b/CLAUDE.md index b994209..e797ecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,5 +278,5 @@ branch; allow workflows to write). For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -`specs/004-orbat-red-green-assets/plan.md` +`specs/005-orbat-asset-enrichment/plan.md` diff --git a/app/js/entities/entities.js b/app/js/entities/entities.js index 6124b5a..00cdf05 100644 --- a/app/js/entities/entities.js +++ b/app/js/entities/entities.js @@ -18,6 +18,7 @@ import { stateAt } from '../kernel/kernel.js'; import { TIDE, fordOpenAt } from '../kernel/world.js'; +import { assetToEntity, hasTrack, ALLEGIANCE_COLOR } from '../orbat/orbat.js'; /** A tidal-parameter set (the shape of {@link TIDE}). */ /** @typedef {{ period_min: number, low_tide_min: number, open_half_width_min: number }} Tide */ @@ -101,11 +102,17 @@ export function satOverhead(t, sat = SAT) { /** * Build the entity set projected by the Sync Matrix (display-only, DEC-52/53 * v1). Aspects are typed time-functions; `at(plan, t)` is the single read used - * by both the track renderer and the cursor readout (NF1). + * by both the track renderer and the cursor readout (NF1). Authored ORBAT + * assets (DEC-60) are folded in as allegiance-typed entities keyed by asset id. + * @param {import('../../../schema/gen/remit').Asset[]} [assets] * @returns {Entities} */ -export function buildEntities() { +export function buildEntities(assets = []) { + /** @type {Entities} */ + const orbatEntities = {}; + for (const a of assets) orbatEntities[a.id] = /** @type {any} */ (assetToEntity(a)); return { + ...orbatEntities, self: { id: 'ent-self', label: 'Own force · ROVER-1', provenance: { kind: 'self' }, @@ -146,13 +153,26 @@ export function buildEntities() { * the World step on. A fuller build would gate rows by role view-preset * (DEC-48/49/50) — here one default preset shows them all. */ -export function syncCatalogue() { - return [ +/** + * @param {import('../../../schema/gen/remit').Asset[]} [assets] + * @returns {import('../views/sync-matrix.js').CatalogueRow[]} + */ +export function syncCatalogue(assets = []) { + /** @type {import('../views/sync-matrix.js').CatalogueRow[]} */ + const rows = [ { key: 'self.phase', entity: 'self', aspect: 'phase', render: 'status', label: 'Own force · phase', needsPlan: true }, { key: 'self.fuel', entity: 'self', aspect: 'fuel', render: 'line', label: 'Own force · fuel %', needsPlan: true }, { key: 'tide.height', entity: 'tide', aspect: 'height', render: 'tide', label: 'Tide · height + window' }, { key: 'sat.pass', entity: 'sat', aspect: 'pass', render: 'band', label: `Recce sat · ${SAT.name} pass` }, ]; + // Any authored asset carrying a time-varying aspect (a red patrol window, a blue + // availability window) projects as a Sync-Matrix track via a catalogue row (DEC-60/US5). + for (const a of assets) { + if (!hasTrack(a)) continue; + rows.push({ key: `${a.id}.active`, entity: a.id, aspect: 'active', render: 'band', + label: `${a.allegiance} · ${a.label ?? a.id}`, color: ALLEGIANCE_COLOR[/** @type {keyof typeof ALLEGIANCE_COLOR} */ (a.allegiance)] }); + } + return rows; } // --------------------------------------------------------------------------- diff --git a/app/js/main.js b/app/js/main.js index 74ba5b7..2cf40aa 100644 --- a/app/js/main.js +++ b/app/js/main.js @@ -14,6 +14,7 @@ import { makeMap } from './views/map.js'; import { makeSyncMatrix } from './views/sync-matrix.js'; import { buildEntities, syncCatalogue, satOverhead, coincidenceRules, coincidenceWindows } from './entities/entities.js'; import { contentId, shortId } from './shapes/canonical.js'; +import { getDraft, setDraft, subscribeDraft, reconcileOwnForce } from './orbat/orbat.js'; // The shared app context — one store/seam/world/playhead, shared with every // other role surface (tab) so they all project the same objects (DEC-61). import { objects, logs, seam, world, playhead } from './shell/context.js'; @@ -76,9 +77,28 @@ const map = makeMap(mapEl, world.baseline, world.ao, world.places); /** @type {((cell: {h3: string, id: number}) => void) | null} */ let mapOnCellClick = null; // active map-click handler (set by Plan in no-go mode) const syncHost = /** @type {HTMLElement} */ (document.getElementById('sync-matrix-host')); const syncMatrix = makeSyncMatrix(syncHost, playhead); -const entities = buildEntities(); -const catalogue = syncCatalogue(); +// Authored ORBAT assets (DEC-60) fold into the entity set + Sync-Matrix catalogue. They are +// rebuilt whenever the live draft changes (panel edits, own-force reconciliation), and the +// projection re-renders — display-only, so no asset ever touches the kernel/plan (NF9). +/** @type {any[]} */ let orbatAssets = (getDraft().assets ?? []); +/** @type {string|null} */ let selectedAsset = null; +let entities = buildEntities(orbatAssets); +let catalogue = syncCatalogue(orbatAssets); const coincRules = coincidenceRules(); + +/** Rebuild entities/catalogue from the current ORBAT draft and re-project. */ +function refreshOrbat(/** @type {any} */ draft) { + orbatAssets = draft?.assets ?? []; + entities = buildEntities(orbatAssets); + catalogue = syncCatalogue(orbatAssets); + renderProjection(); +} +subscribeDraft(refreshOrbat); +// Panel → map/Sync-Matrix selection highlight (US5): a selected row glows in both views. +window.addEventListener('remit:orbat-select', (/** @type {any} */ e) => { + selectedAsset = e.detail?.id ?? null; + renderProjection(); +}); const slider = /** @type {HTMLInputElement} */ (document.getElementById('playhead-slider')); const readout = /** @type {HTMLElement} */ (document.getElementById('projection-readout')); @@ -102,6 +122,7 @@ function renderProjection() { target: mapTarget, rv: mapRv, candidates: mapCandidates, highlight: mapHighlight, obstructions: mapObstructions, nogo: mapNogo, blocked: mapBlocked, + assets: orbatAssets, selectedAsset, // authored ORBAT roster (display-only, DEC-60) follow: state.stage === 'execute', // Execute follow-cam: pan to keep the vehicle in view }); // The Sync Matrix (D6) is the temporal projection — tide + satellite tracks @@ -378,6 +399,12 @@ function mountWorld() { state.ids.configCore = c.id; state.configCoreHash = await contentId(world.configCore); worldProvisioned = true; + // Surface the planned own-force (ROVER-1) as the canonical blue ORBAT asset (idempotent, + // display-only): it keeps driving the plan via the pre-existing machinery (NF9/FR-012). + const ownBase = world.places.base; + setDraft(reconcileOwnForce(getDraft(), { + label: 'Own force · ROVER-1', position: { h3: ownBase.h3, lat: ownBase.lat, lng: ownBase.lng }, + })); // With the AO on the map, show the candidate OPs so Capture can point at // features it can see; the chosen target/RV are set when Capture commits. mapCandidates = world.places.ops; diff --git a/app/js/orbat/orbat.js b/app/js/orbat/orbat.js new file mode 100644 index 0000000..1c5eabd --- /dev/null +++ b/app/js/orbat/orbat.js @@ -0,0 +1,502 @@ +// @ts-check +// orbat/orbat.js — the ORBAT model (DEC-60): a pure, deterministic module over the +// LinkML-generated Orbat/Asset shapes (schema/gen/remit.ts). It is the ONLY writer of +// the authored roster; the panel and the map read through it. Every operation returns a +// NEW draft (no in-place mutation), so identity stays stable and rendering reproducible +// (NF3). Display-only in v1 (NF9 honest floor): nothing here derives adversary behaviour +// or mutates a plan — blue availability/capabilities do not feed routing. +// +// The serialisable shapes are schema-defined and imported (Principle I); the asset→Entity +// adapter (assetToEntity) and the live-draft store below are the documented behaviour/UI +// carve-out and stay hand-written. + +import { canonicalJSON, contentId } from '../shapes/canonical.js'; + +/** @typedef {import('../../../schema/gen/remit').Orbat} Orbat */ +/** @typedef {import('../../../schema/gen/remit').Asset} Asset */ +/** @typedef {import('../../../schema/gen/remit').BlueParams} BlueParams */ +/** @typedef {import('../../../schema/gen/remit').RedParams} RedParams */ +/** @typedef {import('../../../schema/gen/remit').GreenParams} GreenParams */ +/** @typedef {import('../entities/entities.js').Entity} Entity */ + +/** localStorage key for the working draft (per mission). */ +export const DRAFT_KEY = 'remit.orbat.M-001'; + +/** Stable id of the single canonical own-force asset (reconciled from ROVER-1). */ +export const OWN_FORCE_ID = 'own-force'; + +/** The three allegiances (DEC-60). */ +export const ALLEGIANCES = /** @type {const} */ (['blue', 'red', 'green']); + +/** Allegiance display palette (mirrored in docs/project_notes/key_facts.md). */ +export const ALLEGIANCE_COLOR = { blue: '#4493f8', red: '#ff7b72', green: '#38d39f' }; + +/** Tunable numeric bounds — clamped on add/tune (FR-004). */ +export const BOUNDS = { + extent_m: /** @type {[number, number]} */ ([100, 20000]), + severity: /** @type {[number, number]} */ ([1, 5]), + sensitivity: /** @type {[number, number]} */ ([1, 5]), +}; + +/** Allowed green protection rules (the Protection enum). */ +export const PROTECTIONS = /** @type {const} */ (['keep_out', 'minimise_effect']); + +/** Platform kinds (the PlatformKind enum) — drive the map symbol (spec 005, FR-001). */ +export const PLATFORM_KINDS = /** @type {const} */ (['infantry', 'vehicle', 'aircraft', 'vessel', 'sensor', 'emplacement', 'structure']); + +/** Green protected-place categories (the GreenCategory enum, FR-013). */ +export const GREEN_CATEGORIES = /** @type {const} */ (['hospital', 'school', 'utility', 'place_of_worship', 'residential', 'other']); + +/** Intel confidence levels (the existing ConfidenceLevel vocabulary, FR-004). */ +export const CONFIDENCE_LEVELS = /** @type {const} */ (['high', 'medium', 'low']); + +/** Map symbol glyph per platform kind (display/UI carve-out, ADR-0012). Unicode/emoji — + * no icon-atlas asset, rendered via the existing deck.gl TextLayer (research D1). */ +export const SYMBOLS = { + infantry: '👤', vehicle: '🚙', aircraft: '✈', vessel: '🚢', + sensor: '📡', emplacement: '🛡', structure: '🏢', +}; +/** Fallback glyph when no kind is set (today's dot). */ +export const GENERIC_SYMBOL = '●'; + +/** Icon choices offered by the panel's symbol-override dropdown (UI carve-out): the kind + * glyphs plus a few generic markers. The model keeps `symbol` free-text, so a draft may + * still carry a glyph outside this list. */ +export const ICON_CHOICES = /** @type {const} */ ([ + '👤', '🚙', '✈', '🚢', '📡', '🛡', '🏢', '★', '⚑', '⬢', '✚', '⚠', '❓', +]); + +/** Capability tags offered by the blue capabilities multi-select (UI carve-out). The model + * keeps capabilities as open string tags (NF7), so legacy/custom tags still round-trip. */ +export const CAPABILITY_CHOICES = /** @type {const} */ ([ + 'recce', 'comms', 'fires', 'medical', 'ew', 'logistics', 'c2', 'engineer', 'air-defence', 'transport', +]); + +/** Marker emphasis per confidence (FR-004); absent ⇒ full emphasis. */ +export const CONFIDENCE_OPACITY = { high: 1, medium: 0.6, low: 0.35 }; + +/** Default extent (metres) per allegiance. */ +const DEFAULT_EXTENT = { blue: 800, red: 1500, green: 1000 }; + +/** @param {number} v @param {[number, number]} bounds */ +const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v)); + +/** @param {number} v @param {[number, number]} bounds */ +const clampInt = (v, bounds) => Math.round(clamp(v, bounds)); + +/** Trim a free-text value; empty ⇒ undefined (so empties are dropped, not stored blank, FR-014). */ +const cleanStr = (/** @type {any} */ v) => { const s = String(v ?? '').trim(); return s || undefined; }; + +/** Set `obj[key]` to a free-text `value` when non-empty, else delete the key — so cleared + * fields are dropped, never stored blank (FR-014). @param {any} obj @param {string} key @param {any} value */ +function setOrDrop(obj, key, value) { + if (value === undefined || value === null || value === '') delete obj[key]; else obj[key] = value; +} + +/** Set a controlled-vocabulary field: empty ⇒ clear; a valid member ⇒ set; an invalid value ⇒ + * IGNORE (keep the last valid value), never store garbage (FR-013). + * @param {any} obj @param {string} key @param {any} value @param {readonly string[]} vocab */ +function setVocab(obj, key, value, vocab) { + if (value === '' || value == null) { delete obj[key]; return; } + if (vocab.includes(value)) obj[key] = value; // else: ignore +} + +/** Default per-allegiance parameter group. + * @param {'blue'|'red'|'green'} allegiance */ +function defaultParams(allegiance) { + if (allegiance === 'blue') return { blue: { availability: 'available', capabilities: [] } }; + if (allegiance === 'red') return { + red: { + severity: 3, active_windows: [], + detection_range_m: DEFAULT_EXTENT.red, + engagement_range_m: Math.round(0.5 * DEFAULT_EXTENT.red), // engagement ≤ detection + }, + }; + return { green: { sensitivity: 3, protection: 'keep_out' } }; +} + +/** + * Backward-compat normalisation (FR-010): default absent fields and **migrate** spec-004 + * red assets — a red asset with no dual range adopts `detection_range_m` from its prior + * single `extent_m`, seeding `engagement_range_m ≈ 0.5×` (clamped, ≤ detection). Pure + + * idempotent: an already-migrated asset is returned unchanged (same reference), so canonical + * bytes/identity are preserved (NF3). + * @param {Asset} a @returns {Asset} + */ +function normalizeAsset(a) { + if (a.allegiance !== 'red' || a.red?.detection_range_m != null) return a; + const det = clamp(Number(a.extent_m ?? DEFAULT_EXTENT.red), BOUNDS.extent_m); + const eng = Math.min(clamp(Math.round(0.5 * det), BOUNDS.extent_m), det); + return { ...a, red: { ...(a.red ?? {}), detection_range_m: det, engagement_range_m: eng } }; +} + +/** Normalise an ORBAT for loading (FR-010); pure + idempotent. @param {Orbat} orbat @returns {Orbat} */ +export function normalize(orbat) { + return { ...orbat, assets: (orbat.assets ?? []).map(normalizeAsset) }; +} + +/** Default human label for a new asset of an allegiance. + * @param {'blue'|'red'|'green'} allegiance @param {string} id */ +function defaultLabel(allegiance, id) { + const n = id.replace(/^asset-/, '#'); + return { blue: `Own asset ${n}`, red: `Threat ${n}`, green: `Protected place ${n}` }[allegiance]; +} + +/** A fresh, deterministic, unique id within this orbat (asset-, n = max+1). + * Deterministic (no time/random) so equal authoring ⇒ equal bytes (NF3). + * @param {Orbat} orbat */ +function nextId(orbat) { + let max = 0; + for (const a of orbat.assets ?? []) { + const m = /^asset-(\d+)$/.exec(a.id); + if (m) max = Math.max(max, Number(m[1])); + } + return `asset-${max + 1}`; +} + +/** A fresh empty ORBAT (version 1, no assets). + * @param {string} name @returns {Orbat} */ +export function emptyOrbat(name = 'Scenario ORBAT') { + return { id: '', name, version: 1, assets: [], lineage: {} }; +} + +/** Shallow-replace one asset by id, leaving the others byte-identical (FR-002/SC-003). + * @param {Orbat} orbat @param {string} id @param {(a: Asset) => Asset} fn @returns {Orbat} */ +function replaceAsset(orbat, id, fn) { + return { ...orbat, assets: (orbat.assets ?? []).map((a) => (a.id === id ? fn(a) : a)) }; +} + +/** + * Add a default asset of an allegiance at `position`, with a fresh unique id. + * Rejects an out-of-AO position when an `inAO` predicate is supplied (FR-001/003). + * @param {Orbat} orbat + * @param {{ allegiance: 'blue'|'red'|'green', position?: any, label?: string }} seed + * @param {{ inAO?: (pos: any) => boolean }} [opts] + * @returns {{ orbat: Orbat, id: string }} + */ +export function addAsset(orbat, { allegiance, position, label }, opts = {}) { + if (!ALLEGIANCES.includes(allegiance)) throw new Error(`unknown allegiance: ${allegiance}`); + if (position && opts.inAO && !opts.inAO(position)) throw new Error('position is outside the AO'); + const id = nextId(orbat); + /** @type {Asset} */ + const asset = { + id, allegiance, label: label ?? defaultLabel(allegiance, id), + position: position ?? undefined, extent_m: DEFAULT_EXTENT[allegiance], + ...defaultParams(allegiance), + }; + return { orbat: { ...orbat, assets: [...(orbat.assets ?? []), asset] }, id }; +} + +/** + * Duplicate an asset: a deep, independent copy under a NEW id; never copies the + * canonical-own-force flag; the source is unchanged (FR-002/006). + * @param {Orbat} orbat @param {string} id @returns {{ orbat: Orbat, id: string }} + */ +export function duplicateAsset(orbat, id) { + const src = (orbat.assets ?? []).find((a) => a.id === id); + if (!src) throw new Error(`no such asset: ${id}`); + const newId = nextId(orbat); + const copy = structuredClone(src); + copy.id = newId; + delete copy.canonical_own_force; // a duplicate is never the canonical own force + copy.label = `${src.label ?? 'Asset'} (copy)`; + return { orbat: { ...orbat, assets: [...(orbat.assets ?? []), copy] }, id: newId }; +} + +/** + * Apply `patch` to ONLY the targeted asset, clamping/validating its bounds (FR-004). + * Other assets stay byte-identical (SC-003). Returns a new draft. + * @param {Orbat} orbat @param {string} id @param {Partial} patch + * @param {{ inAO?: (pos: any) => boolean }} [opts] + * @returns {Orbat} + */ +export function tuneAsset(orbat, id, patch, opts = {}) { + return replaceAsset(orbat, id, (a) => { + const next = structuredClone(a); + if (patch.label !== undefined) next.label = String(patch.label); + if (patch.position !== undefined) { + if (opts.inAO && !opts.inAO(patch.position)) throw new Error('position is outside the AO'); + next.position = patch.position; + } + if (patch.extent_m !== undefined) next.extent_m = clamp(Number(patch.extent_m), BOUNDS.extent_m); + // Enrichment (spec 005): kind/symbol/confidence + descriptive strength/notes. Vocab-checked + // where applicable; free-text is trimmed and empties are dropped (FR-014), never stored blank. + if ('kind' in patch) setVocab(next, 'kind', /** @type {any} */ (patch).kind, PLATFORM_KINDS); + if ('symbol' in patch) setOrDrop(next, 'symbol', cleanStr(/** @type {any} */ (patch).symbol)); + if ('confidence' in patch) setVocab(next, 'confidence', /** @type {any} */ (patch).confidence, CONFIDENCE_LEVELS); + if ('strength' in patch) setOrDrop(next, 'strength', cleanStr(/** @type {any} */ (patch).strength)); + if ('notes' in patch) setOrDrop(next, 'notes', cleanStr(/** @type {any} */ (patch).notes)); + if (patch.red && next.allegiance === 'red') { + next.red = { ...next.red }; + const pr = /** @type {any} */ (patch.red); + if (pr.severity !== undefined) next.red.severity = clampInt(Number(pr.severity), BOUNDS.severity); + if (pr.active_windows !== undefined) next.red.active_windows = sanitizeWindows(pr.active_windows); + if (pr.detection_range_m !== undefined) next.red.detection_range_m = clamp(Number(pr.detection_range_m), BOUNDS.extent_m); + if (pr.engagement_range_m !== undefined) next.red.engagement_range_m = clamp(Number(pr.engagement_range_m), BOUNDS.extent_m); + // Reconcile the engagement ring to be ≤ the detection ring (FR-006) — never an invalid state. + if (next.red.detection_range_m != null && next.red.engagement_range_m != null && next.red.engagement_range_m > next.red.detection_range_m) + next.red.engagement_range_m = next.red.detection_range_m; + if ('threat_type' in pr) setOrDrop(next.red, 'threat_type', cleanStr(pr.threat_type)); + } + if (patch.green && next.allegiance === 'green') { + next.green = { ...next.green }; + const pg = /** @type {any} */ (patch.green); + if (pg.sensitivity !== undefined) next.green.sensitivity = clampInt(Number(pg.sensitivity), BOUNDS.sensitivity); + if (pg.protection !== undefined && PROTECTIONS.includes(pg.protection)) next.green.protection = pg.protection; + if ('category' in pg) setVocab(next.green, 'category', pg.category, GREEN_CATEGORIES); + } + if (patch.blue && next.allegiance === 'blue') { + next.blue = { ...next.blue }; + if (patch.blue.availability !== undefined) next.blue.availability = String(patch.blue.availability); + if ('role' in patch.blue) setOrDrop(next.blue, 'role', cleanStr(/** @type {any} */ (patch.blue).role)); + if (patch.blue.capabilities !== undefined) + next.blue.capabilities = (patch.blue.capabilities ?? []).map(String).filter(Boolean); + if ('availability_window' in patch.blue) { + const w = /** @type {any} */ (patch.blue).availability_window; + if (w && w.start_min != null && w.end_min != null) { + const s = Math.round(Number(w.start_min)), e = Math.round(Number(w.end_min)); + /** @type {any} */ (next.blue).availability_window = { start_min: Math.min(s, e), end_min: Math.max(s, e) }; + } else { + delete (/** @type {any} */ (next.blue).availability_window); + } + } + } + return next; + }); +} + +/** Clamp a list of time windows to start ≤ end, dropping malformed entries (FR-004). + * @param {any[]} windows */ +function sanitizeWindows(windows) { + return (windows ?? []) + .map((w) => ({ start_min: Math.round(Number(w.start_min)), end_min: Math.round(Number(w.end_min)) })) + .filter((w) => Number.isFinite(w.start_min) && Number.isFinite(w.end_min)) + .map((w) => (w.start_min <= w.end_min ? w : { start_min: w.end_min, end_min: w.start_min })); +} + +/** + * Remove an asset. The canonical own-force asset is PROTECTED — removal is refused + * so the plan stays valid (FR-012). Remaining assets are unaffected (FR-006). + * @param {Orbat} orbat @param {string} id @returns {Orbat} + */ +export function removeAsset(orbat, id) { + const target = (orbat.assets ?? []).find((a) => a.id === id); + if (target?.canonical_own_force) throw new Error('the canonical own-force asset is protected from removal'); + return { ...orbat, assets: (orbat.assets ?? []).filter((a) => a.id !== id) }; +} + +/** + * Surface the existing planned own-force (ROVER-1) as the single canonical blue asset. + * Idempotent: re-running updates the canonical asset in place and guarantees exactly one + * `canonical_own_force = true` (FR-012). The asset is reconciled, never duplicated — the + * plan keeps driving from the pre-existing machinery. + * @param {Orbat} orbat + * @param {{ label?: string, position?: any }} self the planned own-force entity/place + * @returns {Orbat} + */ +export function reconcileOwnForce(orbat, self) { + const assets = (orbat.assets ?? []).map((a) => { + // Strip any stray canonical flag from non-canonical rows (exactly one survives). + if (a.canonical_own_force && a.id !== OWN_FORCE_ID) { + const { canonical_own_force, ...rest } = a; + return /** @type {Asset} */ (rest); + } + return a; + }); + /** @type {Asset} */ + const canonical = { + id: OWN_FORCE_ID, allegiance: 'blue', canonical_own_force: true, + label: self?.label ?? 'Own force · ROVER-1', + position: self?.position ?? undefined, extent_m: DEFAULT_EXTENT.blue, + blue: { availability: 'available', capabilities: ['recce'] }, + }; + const i = assets.findIndex((a) => a.id === OWN_FORCE_ID); + if (i >= 0) { + // Preserve any operator tuning of the pool params; refresh identity/position. + const existing = assets[i]; + assets[i] = { ...canonical, blue: existing.blue ?? canonical.blue, extent_m: existing.extent_m ?? canonical.extent_m }; + } else { + assets.unshift(canonical); + } + return { ...orbat, assets }; +} + +/** + * Validate one asset: allegiance ∈ {blue,red,green}, the matching param group present, + * bounds in range, window start ≤ end, and (when `inAO` is supplied) position in the AO. + * @param {Asset} asset @param {{ inAO?: (pos: any) => boolean }} [opts] + * @returns {{ ok: boolean, issues: string[] }} + */ +export function validate(asset, opts = {}) { + const issues = []; + if (!ALLEGIANCES.includes(/** @type {any} */ (asset.allegiance))) + issues.push(`allegiance must be one of ${ALLEGIANCES.join('/')}`); + else if (!asset[/** @type {'blue'|'red'|'green'} */ (asset.allegiance)]) + issues.push(`${asset.allegiance} asset is missing its ${asset.allegiance} parameter group`); + if (asset.extent_m !== undefined && (asset.extent_m < BOUNDS.extent_m[0] || asset.extent_m > BOUNDS.extent_m[1])) + issues.push(`extent_m out of bounds ${BOUNDS.extent_m.join('..')}`); + if (asset.red?.severity !== undefined && (asset.red.severity < BOUNDS.severity[0] || asset.red.severity > BOUNDS.severity[1])) + issues.push(`severity out of bounds ${BOUNDS.severity.join('..')}`); + if (asset.green?.sensitivity !== undefined && (asset.green.sensitivity < BOUNDS.sensitivity[0] || asset.green.sensitivity > BOUNDS.sensitivity[1])) + issues.push(`sensitivity out of bounds ${BOUNDS.sensitivity.join('..')}`); + for (const w of asset.red?.active_windows ?? []) + if (Number(w.start_min) > Number(w.end_min)) issues.push('active window start_min must be ≤ end_min'); + // Enrichment (spec 005): vocab + red dual-range bounds + engagement ≤ detection. + if (asset.kind !== undefined && !PLATFORM_KINDS.includes(/** @type {any} */ (asset.kind))) issues.push('unknown platform kind'); + if (asset.confidence !== undefined && !CONFIDENCE_LEVELS.includes(/** @type {any} */ (asset.confidence))) issues.push('unknown confidence level'); + if (asset.green?.category !== undefined && !GREEN_CATEGORIES.includes(/** @type {any} */ (asset.green.category))) issues.push('unknown green category'); + const dr = asset.red?.detection_range_m, er = asset.red?.engagement_range_m; + if (dr !== undefined && (dr < BOUNDS.extent_m[0] || dr > BOUNDS.extent_m[1])) issues.push(`detection_range_m out of bounds ${BOUNDS.extent_m.join('..')}`); + if (er !== undefined && (er < BOUNDS.extent_m[0] || er > BOUNDS.extent_m[1])) issues.push(`engagement_range_m out of bounds ${BOUNDS.extent_m.join('..')}`); + if (dr !== undefined && er !== undefined && er > dr) issues.push('engagement_range_m must be ≤ detection_range_m'); + if (asset.position && opts.inAO && !opts.inAO(asset.position)) issues.push('position is outside the AO'); + return { ok: issues.length === 0, issues }; +} + +/** + * Canonical serialisation: assets sorted by id, canonical JSON (DEC-35). The persistence + * & identity form — equal rosters ⇒ equal bytes ⇒ equal content id (NF3). The transient + * `id` field (a content id, derived) is excluded so identity keys off content only. + * @param {Orbat} orbat @returns {string} + */ +export function canonical(orbat) { + return canonicalJSON(canonicalForm(orbat)); +} + +/** The hashed/persisted body: id-free, assets sorted by id. @param {Orbat} orbat */ +function canonicalForm(orbat) { + const assets = [...(orbat.assets ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return { name: orbat.name, version: orbat.version, assets, lineage: orbat.lineage ?? {} }; +} + +/** + * Commit an immutable, content-addressed Orbat version into the shared ObjectStore, with + * `lineage.previous_version` → the prior committed id (Principle V; idempotent re-PUT, + * DEC-35). Identity is the store's content id over the canonical body. + * @param {Orbat} orbat + * @param {import('../stores/stores.js').ObjectStore} objects + * @returns {Promise<{ id: string, existed: boolean }>} + */ +export async function commit(orbat, objects) { + return objects.put('Orbat', canonicalForm(orbat)); +} + +// --- persistence (localStorage draft) ------------------------------------------- +// Guarded so the pure model stays usable under `node --test` (no localStorage there). + +const hasStorage = () => typeof localStorage !== 'undefined'; + +/** Mirror the draft to localStorage as canonical JSON (every mutating op, SC-004). + * @param {Orbat} orbat */ +export function saveDraft(orbat) { + if (hasStorage()) { + try { localStorage.setItem(DRAFT_KEY, canonical(orbat)); } catch { /* quota/private mode — ignore */ } + } +} + +/** Restore the draft (or a fresh empty ORBAT when none is stored), normalised so spec-004 + * drafts migrate cleanly (FR-010). @returns {Orbat} */ +export function loadDraft() { + if (hasStorage()) { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (raw) return normalize(/** @type {Orbat} */ ({ id: '', ...JSON.parse(raw) })); + } catch { /* corrupt — fall through to empty */ } + } + return emptyOrbat(); +} + +/** The map symbol glyph for an asset: explicit override → kind glyph → generic dot (FR-002/003). + * Display/UI carve-out (ADR-0012). @param {Asset} asset @returns {string} */ +export function symbolOf(asset) { + if (asset.symbol) return asset.symbol; + const k = /** @type {keyof typeof SYMBOLS} */ (asset.kind); + return (k && SYMBOLS[k]) || GENERIC_SYMBOL; +} + +/** Marker emphasis (opacity 0..1) for an asset's confidence; absent ⇒ full (FR-004). + * @param {Asset} asset @returns {number} */ +export function confidenceOpacity(asset) { + return CONFIDENCE_OPACITY[/** @type {keyof typeof CONFIDENCE_OPACITY} */ (asset.confidence)] ?? 1; +} + +// --- live draft store (same-page surfaces share one draft) ---------------------- +// The Overview map (main.js) and the ORBAT panel (orbat-panel.js) run in the same realm +// (role tabs), so they share this single live draft and a tiny subscription, rather than +// round-tripping through localStorage on every keystroke. + +/** @type {Orbat | null} */ let _draft = null; +/** @type {Set<(o: Orbat) => void>} */ const _subs = new Set(); + +/** Current live draft (lazily loaded from storage on first read). @returns {Orbat} */ +export function getDraft() { + if (!_draft) _draft = loadDraft(); + return _draft; +} + +/** Replace the live draft, persist it, and notify subscribers. The single writer used by + * the panel after each op and by main.js after reconciliation. @param {Orbat} orbat */ +export function setDraft(orbat) { + _draft = orbat; + saveDraft(orbat); + for (const fn of _subs) fn(orbat); + return orbat; +} + +/** Subscribe to live-draft changes; returns an unsubscribe fn. @param {(o: Orbat) => void} fn */ +export function subscribeDraft(fn) { + _subs.add(fn); + return () => _subs.delete(fn); +} + +// --- display adapter (hand-written carve-out) ----------------------------------- + +/** + * Map an Asset → the buildEntities()-shaped Entity for the Sync Matrix (display-only). + * Allegiance-typed; `provenance.kind` = `self` for the canonical own-force, else `actor`. + * Any asset with a time-varying aspect (red `active_windows`, a blue availability window) + * exposes a `window`-type aspect (reusing the satellite-pass render path) so it appears as + * a Sync-Matrix track. Contains NO kernel reference and alters no plan (NF9). + * @param {Asset} asset @returns {Entity & { allegiance: string, position?: any, asset: Asset }} + */ +export function assetToEntity(asset) { + const windows = activeWindowsOf(asset); + /** @type {Record} */ + const aspects = {}; + if (windows.length) { + aspects.active = { + type: 'window', + at: (/** @type {any} */ _plan, /** @type {number} */ t) => windows.some((w) => t >= w.start && t <= w.end), + windows: (/** @type {number} */ h) => windows + .filter((w) => w.end >= 0 && w.start <= h) + .map((w) => ({ start: Math.max(0, w.start), end: Math.min(h, w.end), center: (w.start + w.end) / 2 })), + }; + } + return { + id: `ent-${asset.id}`, + label: asset.label ?? asset.id, + allegiance: asset.allegiance, + provenance: { kind: asset.canonical_own_force ? 'self' : 'actor' }, + position: asset.position, + asset, + aspects, + }; +} + +/** The time windows an asset is "active": red active_windows, or a blue availability + * window if one is authored. Display-only timing (NF9). @param {Asset} asset */ +function activeWindowsOf(asset) { + if (asset.allegiance === 'red') + return (asset.red?.active_windows ?? []).map((w) => ({ start: Number(w.start_min), end: Number(w.end_min) })); + if (asset.allegiance === 'blue') { + const w = /** @type {any} */ (asset.blue)?.availability_window; + if (w && w.start_min != null && w.end_min != null) return [{ start: Number(w.start_min), end: Number(w.end_min) }]; + } + return []; +} + +/** Does an asset project a Sync-Matrix track (i.e. carry a time-varying aspect)? + * @param {Asset} asset */ +export function hasTrack(asset) { + return activeWindowsOf(asset).length > 0; +} diff --git a/app/js/shell/orbat-panel.js b/app/js/shell/orbat-panel.js new file mode 100644 index 0000000..86b40da --- /dev/null +++ b/app/js/shell/orbat-panel.js @@ -0,0 +1,364 @@ +// @ts-check +// shell/orbat-panel.js — the ORBAT authoring surface (DEC-60/61). A config-declared +// role-tab (home: the `sme-int` role — "red/green entities and threat"). It mounts with the +// shared context and reads/writes the roster ONLY through app/js/orbat/orbat.js (contract: +// orbat-store.md). Display-only (NF9): nothing here alters a route or plan — including blue +// availability/capability tuning. Every mutating affordance mirrors to the live draft +// (persisted to localStorage), which the Overview map + Sync Matrix project. +// +// UI-only render closures (the roster rows, the per-allegiance tuners) are the documented +// behaviour/UI carve-out (ADR-0012 §2) and stay hand-written; the serialisable Asset/Orbat +// shapes are schema-generated and reached through the model module. + +import { + getDraft, setDraft, subscribeDraft, reconcileOwnForce, + addAsset, tuneAsset, duplicateAsset, removeAsset, validate, commit, + ALLEGIANCES, BOUNDS, PROTECTIONS, ALLEGIANCE_COLOR, + PLATFORM_KINDS, GREEN_CATEGORIES, CONFIDENCE_LEVELS, ICON_CHOICES, CAPABILITY_CHOICES, symbolOf, +} from '../orbat/orbat.js'; +import { latLngToId } from '../kernel/hexgrid.js'; +import { shortId } from '../shapes/canonical.js'; + +/** Selection bus — panel → map/Sync-Matrix highlight (US5). main.js listens. */ +const SELECT_EVENT = 'remit:orbat-select'; +/** @param {string|null} id */ +function broadcastSelection(id) { + window.dispatchEvent(new CustomEvent(SELECT_EVENT, { detail: { id } })); +} + +const GROUPS = /** @type {const} */ ([ + { allegiance: 'blue', title: 'Blue (own force)' }, + { allegiance: 'red', title: 'Red (hostile)' }, + { allegiance: 'green', title: 'Green (neutral)' }, +]); + +const esc = (/** @type {any} */ s) => String(s ?? '').replace(/[<&"]/g, (c) => ({ '<': '<', '&': '&', '"': '"' }[c] ?? c)); + +/** + * Mount the ORBAT authoring panel. + * @param {HTMLElement} container + * @param {{ objects: import('../stores/stores.js').ObjectStore, world: any, playhead: any }} ctx + */ +export function mountOrbatPanel(container, ctx) { + const { objects, world } = ctx; + let selected = /** @type {string|null} */ (null); + + // A position is valid if it resolves to a cell inside the AO (FR-001/003 + edge). + const inAO = (/** @type {any} */ pos) => { + if (!pos) return false; + if (pos.h3) return world.ao.idOf.has(pos.h3); + if (typeof pos.lat === 'number' && typeof pos.lng === 'number') + return latLngToId(world.ao, pos.lat, pos.lng) !== undefined; + return false; + }; + + // Default placement: a deterministic in-AO cell, spread by roster size so successive + // adds don't stack (no map-click needed across tabs — the contract's "default AO-centre + // position"). The user re-places via the position tuner. + const defaultPosition = (/** @type {number} */ seq) => { + const ao = world.ao; + const idx = (Math.floor(ao.N / 2) + seq * 11) % ao.N; + const [lat, lng] = ao.centers[idx]; + return { h3: ao.indexes[idx], lat, lng }; + }; + + // Surface the existing planned own-force (ROVER-1) as the canonical blue asset (idempotent). + const base = world.places?.base; + setDraft(reconcileOwnForce(getDraft(), { + label: 'Own force · ROVER-1', + position: base ? { h3: base.h3, lat: base.lat, lng: base.lng } : undefined, + })); + + /** Apply an op that returns a new draft, persist+broadcast, then re-render. setDraft + * notifies the draft subscription below, which is the single render path (no double render). */ + const apply = (/** @type {() => any} */ fn, /** @type {(HTMLElement|null)} */ msgEl = null) => { + try { + const result = fn(); + const next = result && result.orbat ? result.orbat : result; + setDraft(next); + return result; + } catch (err) { + if (msgEl) { msgEl.textContent = `⚠ ${err instanceof Error ? err.message : String(err)}`; msgEl.classList.add('orbat-msg-err'); } + return null; + } + }; + + function render() { + const draft = getDraft(); + const assets = draft.assets ?? []; + container.innerHTML = ` +
+
+

ORBAT — authoring (display-only, NF9)

+

Add, tune, duplicate and remove the scenario's own-force, threat and + neutral assets. Authoring never changes the route or plan; the planned own-force + (ROVER-1) is reconciled as the canonical blue asset and protected from removal.

+
+ + +
+
+ ${GROUPS.map((g) => groupHtml(g, assets)).join('')} +
`; + + // Add buttons. + for (const g of GROUPS) { + container.querySelector(`#orbat-add-${g.allegiance}`)?.addEventListener('click', () => { + const seq = (getDraft().assets ?? []).length; + apply(() => addAsset(getDraft(), { allegiance: g.allegiance, position: defaultPosition(seq) }, { inAO })); + }); + } + + // Per-row wiring. + for (const a of assets) wireRow(a); + + // Commit. + container.querySelector('#orbat-commit')?.addEventListener('click', async () => { + const res = await commit(getDraft(), objects); + // Link the next commit to this version (lineage chain, Principle V). + setDraft({ ...getDraft(), lineage: { previous_version: res.id } }); + const out = /** @type {HTMLElement} */ (container.querySelector('#orbat-commit-result')); + out.innerHTML = `committed ${shortId(res.id)}${res.existed ? ' (unchanged)' : ''}`; + }); + } + + /** @param {{ allegiance: string, title: string }} g @param {any[]} assets */ + function groupHtml(g, assets) { + const rows = assets.filter((a) => a.allegiance === g.allegiance); + const color = ALLEGIANCE_COLOR[/** @type {keyof typeof ALLEGIANCE_COLOR} */ (g.allegiance)]; + return ` +
+
+

${g.title}

+ +
+ ${rows.length + ? `
    ${rows.map(rowHtml).join('')}
` + : `

none

`} +
`; + } + + /** @param {any} a */ + function rowHtml(a) { + const cano = !!a.canonical_own_force; + const sel = selected === a.id ? ' orbat-row-sel' : ''; + return ` +
  • +
    + + + ${cano ? 'canonical own-force' : ''} + + +
    +
    + ${commonTuners(a)} + ${a.allegiance === 'red' ? '' : extentTuner(a)} + ${a.allegiance === 'red' ? redTuners(a) : ''} + ${a.allegiance === 'green' ? greenTuners(a) : ''} + ${a.allegiance === 'blue' ? blueTuners(a) : ''} +
    +
    +
  • `; + } + + // Shared enrichment (spec 005): kind symbol, icon override, intel confidence, strength, notes. + /** @param {any} a */ + function commonTuners(a) { + return ` + + + + + `; + } + + /** The single extent ring tuner (green/blue; red uses dual detection/engagement instead). @param {any} a */ + function extentTuner(a) { + return ` + `; + } + + /** @param {any} a */ + function redTuners(a) { + const w = (a.red?.active_windows ?? [])[0]; + const det = a.red?.detection_range_m ?? 1500; + const eng = a.red?.engagement_range_m ?? Math.round(det * 0.5); + return ` + + + + + `; + } + + /** @param {any} a */ + function greenTuners(a) { + return ` + + + `; + } + + /** @param {any} a */ + function blueTuners(a) { + const win = /** @type {any} */ (a.blue)?.availability_window; + return ` + + + + `; + } + + /** @param {any} a */ + function wireRow(a) { + const row = /** @type {HTMLElement} */ (container.querySelector(`[data-testid="orbat-row-${a.id}"]`)); + if (!row) return; + const msg = /** @type {HTMLElement} */ (row.querySelector('.orbat-msg')); + const on = (/** @type {string} */ act, /** @type {string} */ evt, /** @type {(el: any) => void} */ fn) => + row.querySelector(`[data-act="${act}"]`)?.addEventListener(evt, (e) => fn(e.currentTarget)); + + row.querySelector('[data-act="select"]')?.addEventListener('click', () => { + selected = selected === a.id ? null : a.id; + broadcastSelection(selected); + render(); + }); + on('label', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { label: el.value }), msg)); + on('extent', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { extent_m: Number(el.value) }), msg)); + on('dup', 'click', () => apply(() => duplicateAsset(getDraft(), a.id), msg)); + on('remove', 'click', () => apply(() => removeAsset(getDraft(), a.id), msg)); + + // Shared enrichment (spec 005): kind / icon override / confidence / strength / notes. + on('kind', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { kind: el.value }), msg)); + on('symbol', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { symbol: el.value }), msg)); // '' ⇒ revert to default + on('confidence', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { confidence: el.value }), msg)); + on('strength', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { strength: el.value }), msg)); + on('notes', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { notes: el.value }), msg)); + + if (a.allegiance === 'red') { + on('severity', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { red: { severity: Number(el.value) } }), msg)); + on('detection', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { red: { detection_range_m: Number(el.value) } }), msg)); + on('engagement', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { red: { engagement_range_m: Number(el.value) } }), msg)); + on('threat', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { red: { threat_type: el.value } }), msg)); + const winFromRow = () => { + const onEl = /** @type {HTMLInputElement} */ (row.querySelector('[data-act="redwin-on"]')); + if (!onEl.checked) return []; + const s = Number(/** @type {HTMLInputElement} */ (row.querySelector('[data-act="redwin-start"]')).value || 0); + const e = Number(/** @type {HTMLInputElement} */ (row.querySelector('[data-act="redwin-end"]')).value || 0); + return [{ start_min: s, end_min: e }]; + }; + for (const act of ['redwin-on', 'redwin-start', 'redwin-end']) + on(act, 'change', () => apply(() => tuneAsset(getDraft(), a.id, { red: { active_windows: winFromRow() } }), msg)); + } + if (a.allegiance === 'green') { + on('sensitivity', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { green: { sensitivity: Number(el.value) } }), msg)); + on('protection', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { green: { protection: el.value } }), msg)); + on('category', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { green: { category: el.value } }), msg)); + } + if (a.allegiance === 'blue') { + on('availability', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { blue: { availability: el.value } }), msg)); + on('role', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { blue: { role: el.value } }), msg)); + on('capabilities', 'change', (el) => apply(() => tuneAsset(getDraft(), a.id, { blue: { capabilities: [...el.selectedOptions].map((/** @type {any} */ o) => o.value) } }), msg)); + const bwin = () => { + const onEl = /** @type {HTMLInputElement} */ (row.querySelector('[data-act="bluewin-on"]')); + if (!onEl.checked) return undefined; + const s = Number(/** @type {HTMLInputElement} */ (row.querySelector('[data-act="bluewin-start"]')).value || 0); + const e = Number(/** @type {HTMLInputElement} */ (row.querySelector('[data-act="bluewin-end"]')).value || 0); + return { start_min: Math.min(s, e), end_min: Math.max(s, e) }; + }; + for (const act of ['bluewin-on', 'bluewin-start', 'bluewin-end']) + on(act, 'change', () => apply(() => tuneAsset(getDraft(), a.id, { blue: { availability_window: bwin() } }), msg)); + } + + // Show any standing validation issues (display feedback; never blocks). + const asset = (getDraft().assets ?? []).find((x) => x.id === a.id); + if (asset) { + const v = validate(asset, { inAO }); + if (!v.ok) { msg.textContent = `⚠ ${v.issues.join('; ')}`; msg.classList.add('orbat-msg-err'); } + } + } + + // Re-render if the draft changes elsewhere (e.g. main.js reconciliation). + const unsub = subscribeDraft(() => { if (container.isConnected) render(); }); + container.addEventListener('tab:activated', render); + // Best-effort cleanup if the container is torn down (pop-in/out). + const obs = new MutationObserver(() => { if (!container.isConnected) { unsub(); obs.disconnect(); } }); + if (container.parentNode) obs.observe(container.parentNode, { childList: true }); + + render(); +} diff --git a/app/js/shell/roles.js b/app/js/shell/roles.js index fbe08ef..373aef5 100644 --- a/app/js/shell/roles.js +++ b/app/js/shell/roles.js @@ -28,7 +28,8 @@ export function roles() { { id: 'co', label: 'CO', status: 'stub' }, { id: 'duty-plans', label: 'Duty Officer (Plans)', status: 'stub' }, { id: 'sme-env', label: 'SME Env', status: 'stub' }, - { id: 'sme-int', label: 'SME Int', status: 'stub' }, + { id: 'sme-int', label: 'SME Int', status: 'active', poppable: false, + mount: (c, ctx) => import('./orbat-panel.js').then((m) => m.mountOrbatPanel(c, ctx)) }, { id: 'duty-ops', label: 'Duty Office (Ops)', status: 'stub' }, { id: 'data-analysis', label: 'Data Analysis', status: 'active', poppable: true, mount: (c, ctx) => import('../analysis/data-analysis.js').then((m) => m.mountDataAnalysis(c, ctx)) }, diff --git a/app/js/views/map.js b/app/js/views/map.js index e9bf600..b59e4b0 100644 --- a/app/js/views/map.js +++ b/app/js/views/map.js @@ -13,6 +13,9 @@ import { TERRAIN, fordOpenAt, AO_BOUNDS } from '../kernel/world.js'; import { stateAt } from '../kernel/kernel.js'; import { latLngToId } from '../kernel/hexgrid.js'; import { STRAT_COLORS } from './render.js'; +import { ALLEGIANCE_COLOR, symbolOf, confidenceOpacity } from '../orbat/orbat.js'; + +const ALLEGIANCE_RGB = Object.fromEntries(Object.entries(ALLEGIANCE_COLOR).map(([k, v]) => [k, [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)]])); const rgb = (/** @type {string} */ h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const STRAT_RGB = Object.fromEntries(Object.entries(STRAT_COLORS).map(([k, v]) => [k, rgb(v)])); @@ -61,7 +64,23 @@ export function makeMap(el, baseline, ao, places) { })); map.on('error', () => {}); if (sampling) /** @type {any} */ (window).__map = map; - const overlay = new MapboxOverlay({ interleaved: false, layers: [] }); + // Hovering an ORBAT asset marker surfaces its descriptive detail (spec 005 / T023) — a + // display-only readout (kind, confidence, strength, notes, threat-type/category/role). + const assetTooltip = (/** @type {any} */ info) => { + const a = info?.object?.a; + if (!a) return null; + const desc = a.red?.threat_type || a.green?.category || a.blue?.role; + const lines = [ + a.label ?? a.id, + [a.kind, a.allegiance].filter(Boolean).join(' · '), + desc ? `type: ${desc}` : '', + a.strength ? `strength: ${a.strength}` : '', + a.confidence ? `confidence: ${a.confidence}` : '', + a.notes ? `“${a.notes}”` : '', + ].filter(Boolean); + return { text: lines.join('\n') }; + }; + const overlay = new MapboxOverlay({ interleaved: false, layers: [], getTooltip: /** @type {any} */ (assetTooltip) }); map.addControl(overlay); /** @type {((cell: {h3: string, id: number}) => void) | null} */ @@ -81,9 +100,19 @@ export function makeMap(el, baseline, ao, places) { const idOf = (/** @type {any} */ o) => (o == null ? undefined : (o.id ?? ao.idOf.get(o.h3))); + // ORBAT asset → [lng,lat]: accept either a resolved lat/lng position or a bare H3 cell. + const assetLngLat = (/** @type {any} */ a) => { + const p = a?.position; + if (!p) return null; + if (typeof p.lng === 'number' && typeof p.lat === 'number') return [p.lng, p.lat]; + if (p.h3 && ao.idOf.has(p.h3)) { const id = ao.idOf.get(p.h3); return [ao.centers[id][1], ao.centers[id][0]]; } + return null; + }; + function buildLayers(/** @type {any} */ opts) { const { plans = [], selected = null, t = 0, target = null, rv = null, - candidates = null, highlight = null, obstructions = [], nogo = [], blocked = [] } = opts; + candidates = null, highlight = null, obstructions = [], nogo = [], blocked = [], + assets = [], selectedAsset = null } = opts; const fordOpen = fordOpenAt(t); const nogoIds = [...new Set(nogo.map(idOf).filter((/** @type {any} */ x) => x !== undefined))]; const blockedIds = [...new Set(blocked.map(idOf).filter((/** @type {any} */ x) => x !== undefined))]; @@ -138,12 +167,68 @@ export function makeMap(el, baseline, ao, places) { // Obstruction markers (✕) at lat/lng. if (obstructions.length) layers.push(new TextLayer({ id: 'obstructions', data: obstructions, getPosition: (o) => [o.lng, o.lat], getText: () => '✕', getColor: [255, 123, 114], getSize: 22, fontWeight: 700, outlineWidth: 2, outlineColor: [8, 12, 18, 255] })); + // ORBAT assets (DEC-60 / spec 005) — a kind+allegiance SYMBOL over an allegiance-coloured + // marker, range ring(s) (red: faint detection + bold engagement; others: one extent), and a + // label. Intel CONFIDENCE sets marker/symbol emphasis (opacity). Display-only (NF9): drawn + // from the authored roster, never derived. The selected asset's marker is enlarged/outlined. + const placed = assets.map((/** @type {any} */ a) => ({ a, pos: assetLngLat(a) })).filter((/** @type {any} */ x) => x.pos); + if (placed.length) { + const col = (/** @type {any} */ a) => ALLEGIANCE_RGB[a.allegiance] ?? [200, 210, 220]; + const alpha = (/** @type {any} */ a, /** @type {number} */ base) => Math.round(base * confidenceOpacity(a)); + + // Range rings: red draws an outer (faint) detection ring + an inner (bold) engagement ring; + // green/blue draw their single extent ring. + const rings = []; + for (const { a, pos } of placed) { + const c = col(a); + if (a.allegiance === 'red' && a.red) { + const det = a.red.detection_range_m ?? a.extent_m ?? 1500; + const eng = a.red.engagement_range_m ?? Math.round(det * 0.5); + rings.push({ pos, radius: det, line: [...c, 90], fill: [...c, 10] }); // detection — outer, faint + rings.push({ pos, radius: eng, line: [...c, 210], fill: [...c, 34] }); // engagement — inner, bold + } else { + rings.push({ pos, radius: a.extent_m ?? 800, line: [...c, 150], fill: [...c, 26] }); + } + } + layers.push(new ScatterplotLayer({ + id: 'asset-extents', data: rings, getPosition: (/** @type {any} */ d) => d.pos, + getRadius: (/** @type {any} */ d) => d.radius, radiusUnits: 'meters', stroked: true, filled: true, + getFillColor: /** @type {any} */ ((/** @type {any} */ d) => d.fill), + getLineColor: /** @type {any} */ ((/** @type {any} */ d) => d.line), lineWidthMinPixels: 1.2, + updateTriggers: { getRadius: [assets], getFillColor: [assets], getLineColor: [assets] }, + })); + layers.push(new ScatterplotLayer({ + id: 'asset-marks', data: placed, pickable: true, getPosition: (/** @type {any} */ d) => d.pos, + getRadius: (/** @type {any} */ d) => (selectedAsset && d.a.id === selectedAsset ? 150 : 95), radiusUnits: 'meters', + stroked: true, filled: true, + getFillColor: /** @type {any} */ ((/** @type {any} */ d) => [...col(d.a), alpha(d.a, 255)]), + getLineColor: /** @type {any} */ ((/** @type {any} */ d) => (selectedAsset && d.a.id === selectedAsset ? [240, 246, 252] : [8, 12, 18])), + lineWidthMinPixels: 1.5, + updateTriggers: { getRadius: [selectedAsset, assets], getFillColor: [assets, selectedAsset], getLineColor: [selectedAsset] }, + })); + layers.push(new TextLayer({ + id: 'asset-symbols', data: placed, getPosition: (/** @type {any} */ d) => d.pos, + getText: (/** @type {any} */ d) => symbolOf(d.a), getColor: /** @type {any} */ ((/** @type {any} */ d) => [245, 248, 252, alpha(d.a, 255)]), + getSize: 18, fontWeight: 700, updateTriggers: { getText: [assets], getColor: [assets] }, + })); + layers.push(new TextLayer({ + id: 'asset-labels', data: placed, getPosition: (/** @type {any} */ d) => d.pos, + getText: (/** @type {any} */ d) => d.a.label ?? d.a.id, getColor: [235, 240, 245], getSize: 12, + getPixelOffset: [0, -18], outlineWidth: 2, outlineColor: [8, 12, 18, 255], fontWeight: 700, + updateTriggers: { getText: [assets] }, + })); + } + return layers; } function setData(/** @type {any} */ opts) { - const { selected = null, t = 0, plans = [], highlight = null, nogo = [], blocked = [], obstructions = [] } = opts; + const { selected = null, t = 0, plans = [], highlight = null, nogo = [], blocked = [], obstructions = [], assets = [] } = opts; el.dataset.fordState = fordOpenAt(t) ? 'open' : 'closed'; + // Expose the placed ORBAT roster to the e2e suite: "id:allegiance:kind:confidence" per asset. + const placedAssets = assets.filter((/** @type {any} */ a) => assetLngLat(a)); + el.dataset.assets = placedAssets.map((/** @type {any} */ a) => `${a.id}:${a.allegiance}:${a.kind ?? ''}:${a.confidence ?? ''}`).join('|'); + el.dataset.assetCount = String(placedAssets.length); if (selected) { const g = stateAt(selected, t); el.dataset.ghost = g ? `${g.lng.toFixed(4)},${g.lat.toFixed(4)},${g.phase}` : ''; } else el.dataset.ghost = plans.filter((/** @type {any} */ p) => p.materialisation).map((/** @type {any} */ p) => { const g = stateAt(p, t); return g ? `${p.strategy.key}:${g.lng.toFixed(4)},${g.lat.toFixed(4)}` : ''; }).filter(Boolean).join('|'); el.dataset.highlight = highlight ? shortH3(highlight.h3) : ''; diff --git a/app/js/views/sync-matrix.js b/app/js/views/sync-matrix.js index e9b8fca..9eb2044 100644 --- a/app/js/views/sync-matrix.js +++ b/app/js/views/sync-matrix.js @@ -14,13 +14,19 @@ const W = 720, LBL = 140, PAD_R = 12, TOP = 14, TH = 30, GAP = 10, AX = 22; const PW = W - LBL - PAD_R; -const PROV_COLOR = { self: '#4493f8', forecast: '#7ec8e3', provider: '#bc8cff' }; +const PROV_COLOR = { self: '#4493f8', forecast: '#7ec8e3', provider: '#bc8cff', actor: '#ff7b72' }; const PHASE_COLOR = { transit: '#4493f8', hold: '#6e7681', visit: '#38d39f', exfil: '#e3b341' }; /** @param {string} s */ const esc = (s) => String(s).replace(/[<&]/g, (c) => (c === '<' ? '<' : '&')); /** One Sync-Matrix catalogue row (CONFIG, DEC-53) — see `syncCatalogue()`. */ -/** @typedef {{ key: string, entity: string, aspect: string, render: string, label: string, needsPlan?: boolean }} CatalogueRow */ +/** @typedef {{ key: string, entity: string, aspect: string, render: string, label: string, needsPlan?: boolean, color?: string }} CatalogueRow */ + +/** Convert a #rrggbb hex to an `rgba(r,g,b,a)` string. */ +const rgba = (/** @type {string} */ hex, /** @type {number} */ a) => { + const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${a})`; +}; /** An advisory coincidence window (see `coincidenceWindows()`). */ /** @typedef {import('../entities/entities.js').CoincidenceWindow} CoincidenceWindow */ @@ -236,14 +242,19 @@ function renderTrack(row, { sel, commitment, entities, yTop, tx, horizon }) { return s; } - if (row.render === 'band') { // window track (sat pass) + if (row.render === 'band') { // window track (sat pass / asset) let s = ''; + const tinted = !!row.color; // asset tracks carry an allegiance colour + const stroke = row.color ?? '#bc8cff'; + const fill = tinted ? rgba(row.color ?? '', 0.45) : 'rgba(188,140,255,.45)'; + const seg = tinted ? '●' : 'pass'; + const title = tinted ? 'active' : 'overhead pass'; for (const w of asp.windows(horizon)) { const x = tx(w.start), ww = Math.max(3, tx(w.end) - x); s += ` - overhead pass · H+${Math.round(w.start)}–${Math.round(w.end)}`; - if (ww > 30) s += `pass`; + fill="${fill}" stroke="${stroke}"> + ${title} · H+${Math.round(w.start)}–${Math.round(w.end)}`; + if (ww > 30) s += `${seg}`; } return s; } diff --git a/app/styles.css b/app/styles.css index 34a16fc..6fec385 100644 --- a/app/styles.css +++ b/app/styles.css @@ -422,3 +422,37 @@ input:focus, select:focus, button:focus-visible { outline: 1.5px solid var(--acc .da-side { position: static; } .da-index { max-height: none; } } + +/* --- ORBAT authoring panel (spec 004, DEC-60) ----------------------------- */ +.orbat-panel { padding: 1rem 1.1rem; display: grid; gap: 1rem; } +.orbat-head h2 { margin: 0 0 .2rem; } +.orbat-head .orbat-actions { margin-top: .6rem; } +.orbat-group { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: .7rem .9rem; } +.orbat-group-head { display: flex; align-items: center; justify-content: space-between; gap: .6rem; } +.orbat-group-head h3 { margin: 0; font-size: 1rem; display: flex; align-items: center; gap: .45rem; } +.orbat-group-head .dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; } +.orbat-none { margin: .5rem 0 .1rem; } +.orbat-rows { list-style: none; margin: .6rem 0 0; padding: 0; display: grid; gap: .55rem; } +.orbat-row { background: var(--bg3); border: 1px solid var(--border); border-radius: 10px; padding: .55rem .7rem; display: grid; gap: .45rem; } +.orbat-row-sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; } +.orbat-row-top { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } +.orbat-row-top .orbat-label { flex: 1 1 9rem; min-width: 7rem; } +.orbat-row-top button { padding: .15em .5em; } +.orbat-canon { color: var(--accent); border-color: rgba(68,147,248,.5); } +.orbat-tuners { display: flex; flex-wrap: wrap; gap: .5rem .9rem; align-items: center; font-size: .85rem; } +.orbat-tuners label { display: inline-flex; align-items: center; gap: .35rem; } +.orbat-tuners input[type="range"] { width: 8rem; } +.orbat-tuners .orbat-win-num { width: 4.4rem; } +.orbat-window { gap: .3rem; } +.orbat-msg { font-size: .8rem; min-height: 0; } +.orbat-msg-err { color: var(--warn); } +.orbat-select { background: transparent; } + +/* ORBAT enrichment (spec 005) — compact descriptive inputs + symbol/select sizing. */ +.orbat-tuners select { padding: .15em .3em; } +.orbat-tuners .orbat-icon { width: 2.6rem; text-align: center; } +.orbat-tuners .orbat-desc { width: 5.5rem; } +.orbat-tuners .orbat-notes { width: 9rem; } +.orbat-tuners [data-act="symbol-clear"] { padding: .1em .4em; } +.orbat-tuners .orbat-icon { width: 4.2rem; } +.orbat-tuners .orbat-caps { min-width: 8rem; vertical-align: top; } diff --git a/docs/project_notes/decisions.md b/docs/project_notes/decisions.md index cb88086..3b68337 100644 --- a/docs/project_notes/decisions.md +++ b/docs/project_notes/decisions.md @@ -661,3 +661,72 @@ consequences. Link evidence (e.g. `specs//evidence/`) where relevant. perturbations (they're exogenous operator acts) — capturing the inputs is the prerequisite, automatic re-application is the follow-up. `HexCell` is now the natural type to migrate the documented square-vs-hex `Constraint.cells` drift in `SteeringDelta` onto (bugs.md). + +## ADR-0026 (2026-06-13) — ORBAT blue/red/green authoring scaffolding (display-only) + +- **Context:** the entity catalogue was fixed in config and seeded only a single own + force; a planner could not express the own-force *pool*, the adversary, or the neutral + picture of a scenario (DEC-60). The serialisable ORBAT/asset shape had to live somewhere. +- **Decision:** bring all three sides forward as **display-only authoring scaffolding** + (DEC-56 horizon split, NF9 honest floor). Each asset is a first-class **Entity** (DEC-52) + carrying an `allegiance`, reusing the existing entity → map / Sync-Matrix projection. The + serialisable shape is **schema-defined and regenerated** (Principle I): an `Allegiance` + enum (`blue|red|green`) on `common`, an optional `allegiance` attribute on `Entity`, and a + new `schema/orbat.yaml` module (`Orbat`, `Asset`, `Blue/Red/GreenParams`, `Protection`; + reusing `Waypoint`/`Lineage`/`TimeWindow`). The model (`app/js/orbat/orbat.js`) is a pure, + deterministic writer (add/duplicate/tune/remove/validate/canonical/commit); the panel + (`app/js/shell/orbat-panel.js`, the `sme-int` role-tab) and map read through it. +- **Blue is display-only:** authoring/tuning **never** changes kernel routing in v1. The + existing planned own-force (ROVER-1) is **reconciled** as the single `canonical_own_force` + blue asset (protected from removal) and keeps driving the plan via the pre-existing + machinery; pool blue assets seed the future Scheme allocation (deferred, H2). The + route-unchanged invariant is asserted by an e2e (tune blue → committed Plan ids identical). +- **Options considered:** (a) a bespoke `ThreatMarker`/`RoeZone` type — rejected, duplicates + Entity and breaks one-ontology/three-stances symmetry (DEC-60); (b) a hand-written app + typedef for the persisted shape — rejected, violates Principle I (ADR-0012); (c) wiring + red into `edgeCost` for a plan-around demo — rejected, that is the deferred avoid-assess + capability and would breach NF9. Placement uses a default in-AO position (cross-tab + map-click is impractical: the map lives in the Overview tab, the panel in `sme-int`). +- **Consequences:** add/duplicate/tune/remove multiple instances of each allegiance; + allegiance-coloured map markers (+ faint extent ring + label); Sync-Matrix tracks for + time-windowed assets; the working draft mirrors to `localStorage` (survives reload), + commit mints an immutable content-addressed `Orbat` with lineage. Determinism (NF3): + assets are sorted by `id` and canonicalised before identity/projection. **Not done + (deferred, NF9):** reactive adversary behaviour (DEC-51), capability-matched blue + allocation (H2), live ROE constraint / collateral objective emission (DEC-60 J3). + +## ADR-0027 (2026-06-14) — ORBAT asset enrichment: kind/icons/confidence + red dual-range + descriptive fields (display-only) + +- **Context:** spec 004 rendered every ORBAT asset as a same-looking allegiance-coloured dot with a + single extent ring — no platform type, no intel reliability, a single radius that conflated a + threat's detection and weapon reach, and no descriptive detail. The maintainer asked to enrich the + SME-Int picture (spec 005). +- **Decision:** add **display-only, additive** asset attributes (NF9 honest floor) — all schema-defined + and regenerated (Principle I): a shared `kind` (`PlatformKind` enum) driving a map **symbol** with a + per-asset `symbol` override; an intel `confidence` (reusing `ConfidenceLevel`) shown as marker + **opacity**; red **dual range rings** (`detection_range_m`/`engagement_range_m` on `RedParams`, + `engagement ≤ detection` enforced) replacing the single extent for red; and lightweight descriptive + fields — shared `strength`/`notes`, red `threat_type`, green `category` (`GreenCategory` enum), blue + `role`. Green/blue keep the single `extent_m`. +- **Symbols are TextLayer glyphs, not an icon atlas** (research D1): a hand-written `SYMBOLS` lookup + (kind → Unicode/emoji) rendered via the existing deck.gl `TextLayer` over the allegiance marker — no + bundled image asset, no new dependency, no build step (Principle II). The glyph lookup + `symbolOf`/ + `confidenceOpacity` are the documented UI/behaviour carve-out (ADR-0012); only the *data* is schema. +- **Backward compatibility (FR-010):** a pure, idempotent `normalize()` (wired into `loadDraft`) + migrates spec-004 red drafts — `detection_range_m` adopts the prior single `extent_m`, seeding + `engagement_range_m ≈ 0.5×` (clamped ≤ detection). Absent kind/confidence default to generic + symbol / full emphasis. Canonical bytes/identity are preserved for already-migrated rosters (NF3). +- **Vocab vs free-text semantics:** controlled-vocabulary fields (kind, confidence, green category) + IGNORE an invalid value (keep the last valid one) and clear only on empty (`setVocab`); free-text + fields (symbol, strength, notes, threat_type, role) trim and drop-when-empty (`setOrDrop`/`cleanStr`), + never stored blank (FR-014). +- **Options considered:** (a) a NATO APP-6/2525 symbology engine + affiliation frame shapes — rejected + for v1 (a lookup table, not an engine; colour framing suffices); (b) deck.gl `IconLayer` with an icon + atlas — rejected (needs a bundled image asset); (c) a new numeric confidence scale — rejected (reuse + `ConfidenceLevel`); (d) two `Asset.extent`s shared by all allegiances — rejected (green/blue have no + detect/engage distinction; ranges live on `RedParams`). +- **Consequences:** the map reads like a recognised picture — typed allegiance symbols, a confidence + wash, red see-it/hit-it rings, hover tooltips with descriptive detail. Everything stays display-only + (an e2e re-asserts tune ⇒ committed Plan ids unchanged); spec-004 drafts load intact. **Out of scope + (deferred):** a richer place-on-map interaction and true NATO frame shapes (a later slice); any + routing/kernel influence (the avoid-assess capability, DEC-51). diff --git a/docs/project_notes/issues.md b/docs/project_notes/issues.md index 1a33085..ce00b9f 100644 --- a/docs/project_notes/issues.md +++ b/docs/project_notes/issues.md @@ -54,3 +54,7 @@ evidence (e.g. `specs//evidence/`). | 2026-06-12 | issue [#7](https://github.com/DeepBlueCLtd/REMIT/issues/7) | **Capture execution-phase edits in the model (ADR-0025).** In-flight obstructions/blocks during Execute appended a prose `Observation` to the log but never reached the content-addressed store, so their structured inputs were invisible to Data Analysis and absent for replay (NF3). Modelled them in LinkML — `ExecutionEventKind` + a `HexCell` value object (hex successor to `Waypoint`) in `common`, a concrete `ExecutionDelta(is_a Delta)` in `records` (`event`/`at_min`/`cell`/`delay_min`/`rv_min`/`absorbed_min`); regenerated `schema/gen/` + HTML ref (62→64 classes, 20→21 enums). The wingman now `seam.putObject('ExecutionDelta', …)` on each edit (operator wearing the Ops hat), mirroring `SteeringDelta`; prose log kept, share non-fatal. New e2e capture test; 0 typecheck errors, 12 unit green. Replay re-application deferred (follow-up). | ADR-0025 · `specs/004-capture-execution-deltas/` | | 2026-06-12 | [#8](https://github.com/DeepBlueCLtd/REMIT/pull/8) | **Enforced the JSDoc type-checking the app already declared (ADR-0024).** Every `app/js` file carried `// @ts-check` + JSDoc but nothing ran it; added root `tsconfig.json` (checkJs+strict, noEmit, types:[], DOM/bundler), pinned `typescript`, added `npm run typecheck`, and a `typecheck.yml` CI gate (PR + push-to-main). Drove the app **442 → 0** type errors across 18 files — precise types for DOM/primitives/shared shapes (new typedefs `HexAO`, `Strat`/`Leg`/`RerouteOpts`, `Cell`/`Profile`/`TrajPoint`, `Entity`/`Aspect`…), `any` only for opaque serialisable blobs (plan/requirement/log/viz, per ADR-0012); **zero `@ts-ignore`/`@ts-nocheck`**. Fixed real drift it surfaced: stale pre-hex `wingman.js` annotations (`grid`→`ao`, `{x,y}`→`{h3}` callbacks), two `stateAt` null-guards, an `Entity.domain` tuple, a `@types/node` `setTimeout`→`Timeout` leak, and a documented **SteeringDelta hex-vs-square schema drift** (both in `bugs.md`). No source migrated to `.ts`, no runtime build step added; 12 unit green, e2e in CI. | ADR-0024 · [#8](https://github.com/DeepBlueCLtd/REMIT/pull/8) | + +| 2026-06-13 | `claude/spec-04-implement-0u0s9y` | **ORBAT blue/red/green authoring scaffolding (spec 004, ADR-0026).** Brought all three ORBAT sides forward as **display-only** authoring (DEC-56/NF9): each asset a first-class allegiance-typed `Entity` (DEC-52), reusing the entity → map / Sync-Matrix projection. Schema (Principle I, regenerated): `Allegiance` enum + `Entity.allegiance` + new `schema/orbat.yaml` (`Orbat`/`Asset`/`Blue|Red|GreenParams`/`Protection`, reusing `Waypoint`/`Lineage`/`TimeWindow`). New pure model `app/js/orbat/orbat.js` (add/duplicate/tune/remove/reconcile/validate/canonical/commit + `assetToEntity`, localStorage draft + content-addressed commit with lineage); authoring panel `app/js/shell/orbat-panel.js` (the **SME-Int** role-tab — roster, per-instance tuners, duplicate/remove, commit, selection); allegiance-coloured map markers (`map.js`) + Sync-Matrix tracks for time-windowed assets (`entities.js`/`sync-matrix.js`). **Blue is display-only:** ROVER-1 reconciled as the single `canonical_own_force` blue asset (protected from removal), plan unchanged by any tune (e2e-asserted). Determinism (NF3): assets sorted by id + canonicalised. 11 new unit + 5 new e2e (one per user story) green; 0 typecheck errors. Deferred (NF9): reactive adversary (DEC-51), capability-matched blue allocation (H2), live ROE/collateral emission (DEC-60 J3). | ADR-0026 · `specs/004-orbat-red-green-assets/` | + +| 2026-06-14 | `claude/spec-04-implement-0u0s9y` | **ORBAT asset enrichment (spec 005, ADR-0027).** Display-only, additive enrichment of the SME-Int ORBAT (NF9): shared `kind` (`PlatformKind` enum) → allegiance-framed map **symbols** (deck.gl `TextLayer` glyphs via a `SYMBOLS` lookup, no icon atlas) with a per-asset `symbol` override; intel `confidence` (reusing `ConfidenceLevel`) → marker **opacity**; red **dual range rings** (`detection_range_m`/`engagement_range_m`, `engagement ≤ detection`) replacing the single extent for red; descriptive `strength`/`notes` + red `threat_type` / green `category` (`GreenCategory` enum) / blue `role`. All schema-defined + regenerated (Principle I); model/panel/map extended in place. Backward-compatible: idempotent `normalize()` migrates spec-004 red drafts (`extent_m` → detection). 12 new unit (30 total) + 4 new e2e (9 total) green; 0 typecheck errors. Deferred: place-on-map + NATO frame shapes (later slice); routing influence (DEC-51). | ADR-0027 · `specs/005-orbat-asset-enrichment/` | diff --git a/docs/project_notes/key_facts.md b/docs/project_notes/key_facts.md index 1d07f43..cb6f897 100644 --- a/docs/project_notes/key_facts.md +++ b/docs/project_notes/key_facts.md @@ -37,6 +37,10 @@ need a value. | Data model (LinkML) | **Source of truth:** modular LinkML under `schema/` — modules `common`/`requirement`/`world`/`force`/`entities`/`plan`/`records`, stitched by the entry `schema/remit.yaml` (generators run on the entry; modules import only `common`, cross-module refs resolve at the merge). Generated, never hand-edited: `schema/gen/remit.schema.json`, `schema/gen/remit.ts`, `site/data-model/index.html`. No Pydantic yet. | | Regenerate data model | `bash schema/generate.sh` — bootstraps a LinkML venv (`LINKML_VENV`, default `/tmp/linkml-venv`) and writes all derived artefacts. LinkML must be in a venv (distro pip fails — see bugs.md). | | Data-model reference | `https://deepbluecltd.github.io/REMIT/data-model/` — generated single-page reference (classes/fields/enums + ER diagram); replaces the former hand-authored tour. | +| ORBAT (004) | `app/js/orbat/orbat.js` (pure model: add/duplicate/tune/remove/reconcile/validate/canonical/commit + `assetToEntity`) + `app/js/shell/orbat-panel.js` (the `sme-int` role-tab authoring surface). Schema: `Allegiance` enum + `Entity.allegiance` + `schema/orbat.yaml` (`Orbat`/`Asset`/`Blue|Red|GreenParams`/`Protection`). **Display-only** (NF9): no asset touches the kernel; ROVER-1 reconciled as the single `canonical_own_force` blue asset (id `own-force`, protected from removal). | +| ORBAT allegiance palette (004) | blue (own force) `#4493f8` · red (hostile) `#ff7b72` · green (neutral) `#38d39f` (`ALLEGIANCE_COLOR` in `orbat.js`; mirrored in `map.js` markers + Sync-Matrix tracks). | +| ORBAT bounds / persistence (004) | `extent_m` 100..20000 m · `severity`/`sensitivity` 1..5 · `protection` ∈ {`keep_out`,`minimise_effect`}. Working draft mirrors to `localStorage['remit.orbat.M-001']` (canonical JSON, survives reload); commit mints an immutable content-addressed `Orbat` in the `ObjectStore` with lineage. | +| ORBAT enrichment (005) | Display-only, additive (ADR-0027): `Asset.kind` (`PlatformKind`: infantry/vehicle/aircraft/vessel/sensor/emplacement/structure) → map **symbol** (`SYMBOLS` glyph lookup in `orbat.js`, deck.gl `TextLayer`, no icon atlas) + per-asset `symbol` override; `Asset.confidence` (`ConfidenceLevel`) → marker **opacity** `{high:1, medium:0.6, low:0.35}` (absent ⇒ 1); `Asset.strength`/`notes` (free text); red `RedParams.detection_range_m`/`engagement_range_m` (dual rings, `engagement ≤ detection`) + `threat_type`; green `GreenParams.category` (`GreenCategory`: hospital/school/utility/place_of_worship/residential/other); blue `BlueParams.role`. `normalize()` (in `loadDraft`) migrates spec-004 red drafts `extent_m`→`detection_range_m`. Vocab fields ignore invalid values; free-text trims + drops-empty. | _Pages URLs resolve once GitHub Pages is enabled (served from `gh-pages`). Add anything else worth remembering (service URLs, IDs, constants) as it comes up._ diff --git a/e2e/orbat.spec.ts b/e2e/orbat.spec.ts new file mode 100644 index 0000000..76c7440 --- /dev/null +++ b/e2e/orbat.spec.ts @@ -0,0 +1,308 @@ +// e2e/orbat.spec.ts — ORBAT blue/red/green authoring (spec 004, DEC-60). +// Author → tune → see on the map / Sync Matrix → manage the roster → persist across reload, +// asserting the display-only invariants (NF9 honest floor, NF3 determinism): no asset tune +// changes the route or plan. Captures evidence screenshots along the way. +import { test, expect, Page } from '@playwright/test'; + +const EVIDENCE = process.env.EVIDENCE_DIR || 'specs/004-orbat-red-green-assets/evidence/screenshots'; +const shot = (page: Page, name: string) => + page.screenshot({ path: `${EVIDENCE}/${name}.png`, fullPage: true }); + +const DRAFT_KEY = 'remit.orbat.M-001'; +const draft = (page: Page) => + page.evaluate((k) => JSON.parse(localStorage.getItem(k) || '{}'), DRAFT_KEY); + +/** Provision the world so the Overview map renders (and reconciles the own-force). */ +async function provision(page: Page) { + await page.goto('/'); + await page.getByTestId('world-provision').click(); + await expect(page.getByTestId('map')).toHaveAttribute('data-assets', /own-force:blue/); +} + +async function openOrbat(page: Page) { + await page.getByTestId('tab-sme-int').click(); + await expect(page.getByTestId('orbat-panel')).toBeVisible(); +} + +test('US1 — add & tune red assets; both render, isolated, no fabricated motion', async ({ page }) => { + await provision(page); + await openOrbat(page); + + // Empty red side shows "none". + await expect(page.getByTestId('orbat-none-red')).toBeVisible(); + await shot(page, '01-empty-roster'); + + // Add two red assets. + await page.getByTestId('orbat-add-red').click(); + await page.getByTestId('orbat-add-red').click(); + await expect(page.locator('[data-testid="orbat-group-red"] .orbat-row')).toHaveCount(2); + + const d0 = await draft(page); + const reds = d0.assets.filter((a: any) => a.allegiance === 'red'); + const [r1, r2] = reds.map((a: any) => a.id); + + // Tune the FIRST red's detection range + severity to their maxima (red uses the dual-range + // control from spec 005, not a single extent). + await page.getByTestId(`orbat-detection-${r1}`).fill('20000'); + await page.getByTestId(`orbat-detection-${r1}`).dispatchEvent('change'); + await page.getByTestId(`orbat-severity-${r1}`).fill('5'); + await page.getByTestId(`orbat-severity-${r1}`).dispatchEvent('change'); + + // Isolation (SC-003): only r1 changed; r2 is byte-identical to before. + const before2 = JSON.stringify(reds.find((a: any) => a.id === r2)); + const d1 = await draft(page); + const a1 = d1.assets.find((a: any) => a.id === r1); + const a2 = d1.assets.find((a: any) => a.id === r2); + expect(a1.red.detection_range_m).toBe(20000); + expect(a1.red.severity).toBe(5); + expect(JSON.stringify(a2)).toBe(before2); + + // Both render on the map; honest floor — no plan/route was fabricated by authoring. + await page.getByTestId('tab-overview').click(); + const assetsAttr = await page.getByTestId('map').getAttribute('data-assets'); + expect(assetsAttr).toContain(`${r1}:red`); + expect(assetsAttr).toContain(`${r2}:red`); + const plans = await page.evaluate(() => + (window as any).__remit.objects.list().filter((o: any) => o.type === 'Plan').length); + expect(plans).toBe(0); // authoring created no plan (NF9) + await shot(page, '02-two-red-tuned'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US2 — add & tune green assets; distinct styling, isolated', async ({ page }) => { + await provision(page); + await openOrbat(page); + + await page.getByTestId('orbat-add-green').click(); + await page.getByTestId('orbat-add-green').click(); + await expect(page.locator('[data-testid="orbat-group-green"] .orbat-row')).toHaveCount(2); + + const d0 = await draft(page); + const greens = d0.assets.filter((a: any) => a.allegiance === 'green'); + const [g1, g2] = greens.map((a: any) => a.id); + + await page.getByTestId(`orbat-sensitivity-${g1}`).fill('5'); + await page.getByTestId(`orbat-sensitivity-${g1}`).dispatchEvent('change'); + await page.getByTestId(`orbat-protection-${g1}`).selectOption('minimise_effect'); + + const d1 = await draft(page); + expect(d1.assets.find((a: any) => a.id === g1).green.sensitivity).toBe(5); + expect(d1.assets.find((a: any) => a.id === g1).green.protection).toBe('minimise_effect'); + // g2 untouched. + expect(d1.assets.find((a: any) => a.id === g2).green.sensitivity).toBe(3); + + await page.getByTestId('tab-overview').click(); + const attr = await page.getByTestId('map').getAttribute('data-assets'); + expect(attr).toContain(`${g1}:green`); + expect(attr).toContain(`${g2}:green`); + await shot(page, '03-green-assets'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US3 — own-force reconciled & protected; blue tune leaves the route unchanged', async ({ page }) => { + // Walk to a committed set of COAs so there is a route to prove unchanged. + await page.goto('/'); + await page.getByTestId('world-provision').click(); + await page.getByTestId('continue-capture').click(); + await page.getByTestId('cap-commit').click(); + await page.getByTestId('continue-plan').click(); + await page.getByTestId('plan-run').click(); + await expect(page.locator('.plan-card')).toHaveCount(2); + + // Snapshot the committed Plan ids (the route identity). + const plansBefore = await page.evaluate(() => + (window as any).__remit.objects.list().filter((o: any) => o.type === 'Plan').map((o: any) => o.id).sort()); + expect(plansBefore.length).toBe(2); + + await openOrbat(page); + // The planned own-force (ROVER-1) is surfaced as the canonical blue asset, remove disabled. + await expect(page.getByTestId('orbat-canon')).toBeVisible(); + await expect(page.getByTestId('orbat-remove-own-force')).toBeDisabled(); + + // Add a blue pool asset and tune it. + await page.getByTestId('orbat-add-blue').click(); + const d0 = await draft(page); + const blue = d0.assets.find((a: any) => a.allegiance === 'blue' && !a.canonical_own_force); + await page.getByTestId(`orbat-availability-${blue.id}`).selectOption('down'); + await page.getByTestId(`orbat-capabilities-${blue.id}`).selectOption(['recce', 'comms']); + + // Display-only proof (NF9): the committed Plans are byte-for-byte the same set. + const plansAfter = await page.evaluate(() => + (window as any).__remit.objects.list().filter((o: any) => o.type === 'Plan').map((o: any) => o.id).sort()); + expect(plansAfter).toEqual(plansBefore); + + await page.getByTestId('tab-overview').click(); + await expect(page.getByTestId('map')).toHaveAttribute('data-assets', new RegExp(`${blue.id}:blue`)); + await shot(page, '04-blue-pool-route-unchanged'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US4 — duplicate, remove, and persistence across reload', async ({ page }) => { + await provision(page); + await openOrbat(page); + + await page.getByTestId('orbat-add-red').click(); + await page.getByTestId('orbat-add-green').click(); + let d = await draft(page); + const red = d.assets.find((a: any) => a.allegiance === 'red'); + const green = d.assets.find((a: any) => a.allegiance === 'green'); + + // Duplicate the red → an independent copy (new id). + await page.getByTestId(`orbat-dup-${red.id}`).click(); + await expect(page.locator('[data-testid="orbat-group-red"] .orbat-row')).toHaveCount(2); + + // Remove the green → gone; the canonical own-force cannot be removed. + await page.getByTestId(`orbat-remove-${green.id}`).click(); + await expect(page.getByTestId('orbat-none-green')).toBeVisible(); + await expect(page.getByTestId('orbat-remove-own-force')).toBeDisabled(); + + // Commit the roster (mints an immutable version). + await page.getByTestId('orbat-commit').click(); + await expect(page.getByTestId('orbat-commit-result')).toContainText('committed'); + + const rosterBefore = (await draft(page)).assets + .map((a: any) => `${a.id}:${a.allegiance}:${a.extent_m}`).sort(); + await shot(page, '05-roster-managed'); + + // Reload → the full roster and tuned values are restored exactly (SC-004). + await page.reload(); + await page.getByTestId('tab-sme-int').click(); + await expect(page.getByTestId('orbat-panel')).toBeVisible(); + const rosterAfter = (await draft(page)).assets + .map((a: any) => `${a.id}:${a.allegiance}:${a.extent_m}`).sort(); + expect(rosterAfter).toEqual(rosterBefore); + await expect(page.getByTestId('orbat-none-green')).toBeVisible(); // removal persisted + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US5 — a red active window projects as a Sync-Matrix track; selection highlights it', async ({ page }) => { + await provision(page); + + // Baseline catalogue track count on the Overview Sync Matrix. + const tracks0 = Number(await page.getByTestId('sync-matrix-host').getAttribute('data-tracks')); + + await openOrbat(page); + await page.getByTestId('orbat-add-red').click(); + const d = await draft(page); + const red = d.assets.find((a: any) => a.allegiance === 'red'); + + // Give it an active window (H+30..H+60). + await page.getByTestId(`orbat-redwin-${red.id}`).check(); + await page.locator(`[data-testid="orbat-row-${red.id}"] [data-act="redwin-start"]`).fill('30'); + await page.locator(`[data-testid="orbat-row-${red.id}"] [data-act="redwin-end"]`).fill('60'); + await page.locator(`[data-testid="orbat-row-${red.id}"] [data-act="redwin-end"]`).dispatchEvent('change'); + + // Select the row → broadcast highlight to the other views. + await page.locator(`[data-testid="orbat-row-${red.id}"] [data-act="select"]`).click(); + await expect(page.locator(`[data-testid="orbat-row-${red.id}"]`)).toHaveClass(/orbat-row-sel/); + + // On the Overview the Sync-Matrix gains the asset's track. + await page.getByTestId('tab-overview').click(); + await expect + .poll(() => page.getByTestId('sync-matrix-host').getAttribute('data-tracks')) + .toBe(String(tracks0 + 1)); + await expect(page.getByTestId('map')).toHaveAttribute('data-assets', new RegExp(`${red.id}:red`)); + await shot(page, '06-sync-matrix-track'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +// --- spec 005 enrichment --------------------------------------------------- + +test('US1 (005) — platform kind drives the map symbol; icon override + clear', async ({ page }) => { + await provision(page); + await openOrbat(page); + await page.getByTestId('orbat-add-red').click(); + const id = (await draft(page)).assets.find((a: any) => a.allegiance === 'red').id; + + // Set kind → the map's data-assets exposes "id:red:emplacement:...". + await page.getByTestId(`orbat-kind-${id}`).selectOption('emplacement'); + await page.getByTestId('tab-overview').click(); + await expect(page.getByTestId('map')).toHaveAttribute('data-assets', new RegExp(`${id}:red:emplacement`)); + + // Icon override (dropdown) then clear via the "—" option (asserted on the persisted draft). + await page.getByTestId('tab-sme-int').click(); + await page.getByTestId(`orbat-symbol-${id}`).selectOption('★'); + expect((await draft(page)).assets.find((a: any) => a.id === id).symbol).toBe('★'); + await page.getByTestId(`orbat-symbol-${id}`).selectOption(''); // — = revert to kind default + expect('symbol' in (await draft(page)).assets.find((a: any) => a.id === id)).toBe(false); + + // Honest floor: authoring created no plan. + await page.getByTestId('tab-overview').click(); + const plans = await page.evaluate(() => (window as any).__remit.objects.list().filter((o: any) => o.type === 'Plan').length); + expect(plans).toBe(0); + await shot(page, '07-kind-symbols'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US2 (005) — intel confidence persists and shows on the map', async ({ page }) => { + await provision(page); + await openOrbat(page); + await page.getByTestId('orbat-add-green').click(); + const id = (await draft(page)).assets.find((a: any) => a.allegiance === 'green').id; + + await page.getByTestId(`orbat-confidence-${id}`).selectOption('low'); + expect((await draft(page)).assets.find((a: any) => a.id === id).confidence).toBe('low'); + await page.getByTestId('tab-overview').click(); + await expect(page.getByTestId('map')).toHaveAttribute('data-assets', new RegExp(`${id}:green:[^:]*:low`)); + + // Persists across reload. + await page.reload(); + expect((await draft(page)).assets.find((a: any) => a.id === id).confidence).toBe('low'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US3 (005) — red dual range reconciles engagement ≤ detection; green keeps single extent', async ({ page }) => { + await provision(page); + await openOrbat(page); + await page.getByTestId('orbat-add-red').click(); + await page.getByTestId('orbat-add-green').click(); + const d0 = await draft(page); + const red = d0.assets.find((a: any) => a.allegiance === 'red'); + const green = d0.assets.find((a: any) => a.allegiance === 'green'); + + // Red shows detection + engagement (not a single extent); green shows a single extent. + await expect(page.getByTestId(`orbat-detection-${red.id}`)).toBeVisible(); + await expect(page.getByTestId(`orbat-engagement-${red.id}`)).toBeVisible(); + await expect(page.getByTestId(`orbat-extent-${red.id}`)).toHaveCount(0); + await expect(page.getByTestId(`orbat-extent-${green.id}`)).toBeVisible(); + + // Set detection then an over-large engagement → reconciled to ≤ detection. + await page.getByTestId(`orbat-detection-${red.id}`).fill('4000'); + await page.getByTestId(`orbat-detection-${red.id}`).dispatchEvent('change'); + await page.getByTestId(`orbat-engagement-${red.id}`).fill('9000'); + await page.getByTestId(`orbat-engagement-${red.id}`).dispatchEvent('change'); + const a = (await draft(page)).assets.find((x: any) => x.id === red.id); + expect(a.red.detection_range_m).toBe(4000); + expect(a.red.engagement_range_m).toBe(4000); // reconciled ≤ detection + await shot(page, '08-red-dual-range'); + await expect(page.locator('#fault')).toBeHidden(); +}); + +test('US4 (005) — descriptive fields (strength/notes + threat/category/role) round-trip', async ({ page }) => { + await provision(page); + await openOrbat(page); + await page.getByTestId('orbat-add-red').click(); + await page.getByTestId('orbat-add-green').click(); + await page.getByTestId('orbat-add-blue').click(); + const d0 = await draft(page); + const red = d0.assets.find((a: any) => a.allegiance === 'red'); + const green = d0.assets.find((a: any) => a.allegiance === 'green'); + const blue = d0.assets.find((a: any) => a.allegiance === 'blue' && !a.canonical_own_force); + + for (const [tid, val] of [[`orbat-strength-${red.id}`, '×2'], [`orbat-notes-${red.id}`, 'dug in'], [`orbat-threat-${red.id}`, 'SAM'], [`orbat-role-${blue.id}`, 'recce']] as const) { + await page.getByTestId(tid).fill(val); + await page.getByTestId(tid).dispatchEvent('change'); + } + await page.getByTestId(`orbat-category-${green.id}`).selectOption('hospital'); + + // Persist across reload, then assert every descriptor survived. + await page.reload(); + const d1 = await draft(page); + const r = d1.assets.find((a: any) => a.id === red.id); + expect(r.strength).toBe('×2'); + expect(r.notes).toBe('dug in'); + expect(r.red.threat_type).toBe('SAM'); + expect(d1.assets.find((a: any) => a.id === green.id).green.category).toBe('hospital'); + expect(d1.assets.find((a: any) => a.id === blue.id).blue.role).toBe('recce'); + await expect(page.locator('#fault')).toBeHidden(); +}); diff --git a/schema/common.yaml b/schema/common.yaml index 22cbc72..71f2958 100644 --- a/schema/common.yaml +++ b/schema/common.yaml @@ -83,6 +83,51 @@ enums: high: {} medium: {} low: {} + Allegiance: + description: >- + Side typing on an entity (DEC-60). Selects the kernel STANCE — plan-for (blue) / + avoid-assess (red) / respect (green). v1 is display-only (NF9 honest floor). + permissible_values: + blue: + description: own force + red: + description: hostile / adversary (threat source; passive in v1) + green: + description: neutral / host-nation / civilian (ROE & collateral; inert in v1) + PlatformKind: + description: >- + The platform/type class of an ORBAT asset (DEC-60). Orthogonal to allegiance; drives the + map symbol. Display-only in v1 (NF9). v1 set, pluggable like ActivityType (NF7). + permissible_values: + infantry: + description: dismounted personnel + vehicle: + description: ground vehicle + aircraft: + description: fixed / rotary wing + vessel: + description: surface / sub-surface craft + sensor: + description: sensor / radar / observation post + emplacement: + description: fixed weapon / SAM / fortified position + structure: + description: building / installation / facility + GreenCategory: + description: The category of a neutral / green protected place (DEC-60 J3); display-only in v1. + permissible_values: + hospital: + description: medical facility + school: + description: educational facility + utility: + description: power / water / comms infrastructure + place_of_worship: + description: religious site + residential: + description: populated residential area + other: + description: uncategorised protected place AspectType: description: The render-class of one time-varying facet of an entity (DEC-52/53). permissible_values: diff --git a/schema/entities.yaml b/schema/entities.yaml index 3e4fd24..ce6fbe3 100644 --- a/schema/entities.yaml +++ b/schema/entities.yaml @@ -26,6 +26,9 @@ classes: provenance: range: DataProvenance inlined: true + allegiance: + range: Allegiance + description: side typing (DEC-60); absent ⇒ unaligned / own-context as today aspects: range: Aspect multivalued: true diff --git a/schema/gen/remit.schema.json b/schema/gen/remit.schema.json index a41faaa..e881d43 100644 --- a/schema/gen/remit.schema.json +++ b/schema/gen/remit.schema.json @@ -261,6 +261,16 @@ "title": "AlertCauseType", "type": "string" }, + "Allegiance": { + "description": "Side typing on an entity (DEC-60). Selects the kernel STANCE \u2014 plan-for (blue) / avoid-assess (red) / respect (green). v1 is display-only (NF9 honest floor).", + "enum": [ + "blue", + "red", + "green" + ], + "title": "Allegiance", + "type": "string" + }, "Ambiguity": { "additionalProperties": false, "description": "", @@ -416,6 +426,119 @@ "title": "AspectType", "type": "string" }, + "Asset": { + "additionalProperties": false, + "description": "One ORBAT entry \u2014 a first-class located thing (DEC-52) typed by allegiance, with independently-tunable parameters. Display-only in v1.", + "properties": { + "allegiance": { + "$ref": "#/$defs/Allegiance" + }, + "blue": { + "anyOf": [ + { + "$ref": "#/$defs/BlueParams" + }, + { + "type": "null" + } + ], + "description": "present iff allegiance = blue" + }, + "canonical_own_force": { + "description": "true on the single blue asset reconciled from the existing planned own-force (ROVER-1); it drives the plan via the existing machinery and is protected from removal.", + "type": [ + "boolean", + "null" + ] + }, + "confidence": { + "$ref": "#/$defs/ConfidenceLevel", + "description": "intel reliability of this asset (DEC-19); rendered as marker emphasis + a roster badge" + }, + "extent_m": { + "description": "reach / footprint radius in metres", + "type": [ + "number", + "null" + ] + }, + "green": { + "anyOf": [ + { + "$ref": "#/$defs/GreenParams" + }, + { + "type": "null" + } + ], + "description": "present iff allegiance = green" + }, + "id": { + "description": "stable per-instance identity (not the label)", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/PlatformKind", + "description": "platform type (DEC-60); drives the map symbol. Absent \u21d2 generic marker." + }, + "label": { + "description": "human label; need not be unique", + "type": [ + "string", + "null" + ] + }, + "notes": { + "description": "free-text operator notes; display-only", + "type": [ + "string", + "null" + ] + }, + "position": { + "anyOf": [ + { + "$ref": "#/$defs/Waypoint" + }, + { + "type": "null" + } + ], + "description": "AO location (H3 cell / lat-lon)" + }, + "red": { + "anyOf": [ + { + "$ref": "#/$defs/RedParams" + }, + { + "type": "null" + } + ], + "description": "present iff allegiance = red" + }, + "strength": { + "description": "free-text strength descriptor (e.g. \"\u00d73\", \"platoon\"); display-only", + "type": [ + "string", + "null" + ] + }, + "symbol": { + "description": "optional per-asset symbol override (a glyph); absent \u21d2 derived from kind + allegiance", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "allegiance" + ], + "title": "Asset", + "type": "object" + }, "Attribution": { "additionalProperties": false, "description": "Who decided, under what authority, when. Stamped on every committing act \u2014 this is universal rule 2 (DEC-15, NF2).", @@ -550,6 +673,49 @@ "title": "Baseline", "type": "object" }, + "BlueParams": { + "additionalProperties": false, + "description": "Own-force pool member (capability-matched ALLOCATION deferred to H2; display-only in v1 \u2014 does not drive routing). The capability vocabulary is the seam a future Scheme matches to a requirement's activity needs (DEC-59/60).", + "properties": { + "availability": { + "description": "\"available | down\" (own State mirror, DEC-52)", + "type": [ + "string", + "null" + ] + }, + "availability_window": { + "anyOf": [ + { + "$ref": "#/$defs/TimeWindow" + }, + { + "type": "null" + } + ], + "description": "mission-minute window the asset is available (display-only Sync-Matrix track)" + }, + "capabilities": { + "description": "capability tags a future Scheme matches to activity needs (stub)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "role": { + "description": "free-text own-force role (e.g. recce, C2, fires); display-only", + "type": [ + "string", + "null" + ] + } + }, + "title": "BlueParams", + "type": "object" + }, "Capture": { "additionalProperties": false, "description": "The negotiation record behind a commitment \u2014 answers, the canonical contract text, open ambiguities (DEC-17).", @@ -1205,6 +1371,10 @@ "additionalProperties": false, "description": "Anything located with an identity \u2014 yourself, an actor, a feature, a phenomenon (DEC-52). Where a Channel is a field, an Entity is a thing, projected across synchronised views. To affect the plan it is *cast* to a Channel or Commitment (actor \u2192 moving risk channel; window \u2192 timing commitment); in v1 entities are display-only. Skeleton note: aspects are keyed by name in an object (phase/fuel/height/pass); modelled here as a list of named Aspects.", "properties": { + "allegiance": { + "$ref": "#/$defs/Allegiance", + "description": "side typing (DEC-60); absent \u21d2 unaligned / own-context as today" + }, "aspects": { "items": { "$ref": "#/$defs/Aspect" @@ -1437,6 +1607,42 @@ "title": "FactLayer", "type": "object" }, + "GreenCategory": { + "description": "The category of a neutral / green protected place (DEC-60 J3); display-only in v1.", + "enum": [ + "hospital", + "school", + "utility", + "place_of_worship", + "residential", + "other" + ], + "title": "GreenCategory", + "type": "string" + }, + "GreenParams": { + "additionalProperties": false, + "description": "Neutral / collateral picture (ROE & collateral emission deferred; inert in v1, DEC-60 J3).", + "properties": { + "category": { + "$ref": "#/$defs/GreenCategory", + "description": "kind of protected place (hospital / school / \u2026); display-only" + }, + "protection": { + "$ref": "#/$defs/Protection", + "description": "nature of the rule (tagged for the future hard/soft split)" + }, + "sensitivity": { + "description": "graded collateral weight (e.g. 1..5)", + "type": [ + "integer", + "null" + ] + } + }, + "title": "GreenParams", + "type": "object" + }, "Grid2D": { "additionalProperties": false, "description": "", @@ -1724,6 +1930,52 @@ "title": "Observation", "type": "object" }, + "Orbat": { + "additionalProperties": false, + "description": "The roster of participants & potential participants for a scenario \u2014 the authoring root (DEC-60). Versioned & immutable when committed (lineage); the editable working draft mirrors to localStorage. v1 authors the red & green sides; blue is the existing own force.", + "properties": { + "assets": { + "items": { + "$ref": "#/$defs/Asset" + }, + "type": [ + "array", + "null" + ] + }, + "id": { + "description": "content id of the canonical form (DEC-35)", + "type": "string" + }, + "lineage": { + "anyOf": [ + { + "$ref": "#/$defs/Lineage" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "id" + ], + "title": "Orbat", + "type": "object" + }, "Plan": { "additionalProperties": false, "description": "A candidate solution whose id IS the hash of its Stamp (DEC-5/22/29). Its materialisation (schedule, trajectory, state curves) is cached and regenerable; its scores and first-class conflicts make it comparable. The skeleton also carries the chosen `strategy` and the `tide_decision` weighing.", @@ -1802,6 +2054,20 @@ "title": "Plan", "type": "object" }, + "PlatformKind": { + "description": "The platform/type class of an ORBAT asset (DEC-60). Orthogonal to allegiance; drives the map symbol. Display-only in v1 (NF9). v1 set, pluggable like ActivityType (NF7).", + "enum": [ + "infantry", + "vehicle", + "aircraft", + "vessel", + "sensor", + "emplacement", + "structure" + ], + "title": "PlatformKind", + "type": "string" + }, "Predictability": { "description": "How predictable a channel is over time (DEC-21) \u2014 drives sampling.", "enum": [ @@ -1880,6 +2146,15 @@ "title": "Profile", "type": "object" }, + "Protection": { + "description": "The nature of a green asset's protection rule (tagged for the future DEC-60 J3 hard/soft split).", + "enum": [ + "keep_out", + "minimise_effect" + ], + "title": "Protection", + "type": "string" + }, "Realisation": { "description": "How a channel or movement model is realised (DEC-49). `provider` = a computed service behind the seam.", "enum": [ @@ -1891,6 +2166,52 @@ "title": "Realisation", "type": "string" }, + "RedParams": { + "additionalProperties": false, + "description": "Hostile threat picture (threat SOURCE only in v1; reactive behaviour deferred, DEC-51).", + "properties": { + "active_windows": { + "description": "mission-minute windows the threat is active (Sync-Matrix track)", + "items": { + "$ref": "#/$defs/TimeWindow" + }, + "type": [ + "array", + "null" + ] + }, + "detection_range_m": { + "description": "outer reach the threat can DETECT within (metres); the faint outer ring", + "type": [ + "number", + "null" + ] + }, + "engagement_range_m": { + "description": "inner reach the threat can ENGAGE within (metres); the bold inner ring (\u2264 detection)", + "type": [ + "number", + "null" + ] + }, + "severity": { + "description": "graded threat severity (e.g. 1..5)", + "type": [ + "integer", + "null" + ] + }, + "threat_type": { + "description": "free-text threat / weapon type (e.g. SAM, MG, armour); display-only", + "type": [ + "string", + "null" + ] + } + }, + "title": "RedParams", + "type": "object" + }, "Replan": { "additionalProperties": false, "description": "A move from one committed plan to another, with rationale (DEC-36). Not yet logged in the v1 skeleton.", diff --git a/schema/gen/remit.ts b/schema/gen/remit.ts index 3e95522..71ce42d 100644 --- a/schema/gen/remit.ts +++ b/schema/gen/remit.ts @@ -6,6 +6,8 @@ export type ExcursionId = string; export type ProfileId = string; export type EntityId = string; export type AspectName = string; +export type OrbatId = string; +export type AssetId = string; export type PlanId = string; export type ConflictId = string; export type SelectionRationaleId = string; @@ -107,6 +109,56 @@ export enum ConfidenceLevel { low = "low", }; /** +* Side typing on an entity (DEC-60). Selects the kernel STANCE — plan-for (blue) / avoid-assess (red) / respect (green). v1 is display-only (NF9 honest floor). +*/ +export enum Allegiance { + + /** own force */ + blue = "blue", + /** hostile / adversary (threat source; passive in v1) */ + red = "red", + /** neutral / host-nation / civilian (ROE & collateral; inert in v1) */ + green = "green", +}; +/** +* The platform/type class of an ORBAT asset (DEC-60). Orthogonal to allegiance; drives the map symbol. Display-only in v1 (NF9). v1 set, pluggable like ActivityType (NF7). +*/ +export enum PlatformKind { + + /** dismounted personnel */ + infantry = "infantry", + /** ground vehicle */ + vehicle = "vehicle", + /** fixed / rotary wing */ + aircraft = "aircraft", + /** surface / sub-surface craft */ + vessel = "vessel", + /** sensor / radar / observation post */ + sensor = "sensor", + /** fixed weapon / SAM / fortified position */ + emplacement = "emplacement", + /** building / installation / facility */ + structure = "structure", +}; +/** +* The category of a neutral / green protected place (DEC-60 J3); display-only in v1. +*/ +export enum GreenCategory { + + /** medical facility */ + hospital = "hospital", + /** educational facility */ + school = "school", + /** power / water / comms infrastructure */ + utility = "utility", + /** religious site */ + place_of_worship = "place_of_worship", + /** populated residential area */ + residential = "residential", + /** uncategorised protected place */ + other = "other", +}; +/** * The render-class of one time-varying facet of an entity (DEC-52/53). */ export enum AspectType { @@ -223,6 +275,16 @@ export enum ExecutionEventKind { /** a cell ahead declared impassable, forcing an in-flight re-route around it */ block = "block", }; +/** +* The nature of a green asset's protection rule (tagged for the future DEC-60 J3 hard/soft split). +*/ +export enum Protection { + + /** no-go / no-strike area (future HARD constraint) */ + keep_out = "keep_out", + /** collateral to be minimised (future SOFT objective) */ + minimise_effect = "minimise_effect", +}; /** @@ -685,6 +747,8 @@ export interface Entity { /** conceptual kind (DEC-52) — one of: self, actor, feature, phenomenon. Optional; the skeleton folds this into provenance.kind. A documented string rather than an enum (see the AspectType note). */ kind?: string, provenance?: DataProvenance, + /** side typing (DEC-60); absent ⇒ unaligned / own-context as today */ + allegiance?: string, aspects?: Aspect[], } @@ -705,6 +769,98 @@ export interface Aspect { } +/** + * The roster of participants & potential participants for a scenario — the authoring root (DEC-60). Versioned & immutable when committed (lineage); the editable working draft mirrors to localStorage. v1 authors the red & green sides; blue is the existing own force. + */ +export interface Orbat { + /** content id of the canonical form (DEC-35) */ + id: string, + name?: string, + version?: number, + assets?: Asset[], + lineage?: Lineage, +} + + +/** + * One ORBAT entry — a first-class located thing (DEC-52) typed by allegiance, with independently-tunable parameters. Display-only in v1. + */ +export interface Asset { + /** stable per-instance identity (not the label) */ + id: string, + allegiance: string, + /** human label; need not be unique */ + label?: string, + /** AO location (H3 cell / lat-lon) */ + position?: Waypoint, + /** reach / footprint radius in metres */ + extent_m?: number, + /** present iff allegiance = blue */ + blue?: BlueParams, + /** present iff allegiance = red */ + red?: RedParams, + /** present iff allegiance = green */ + green?: GreenParams, + /** true on the single blue asset reconciled from the existing planned own-force (ROVER-1); it drives the plan via the existing machinery and is protected from removal. */ + canonical_own_force?: boolean, + /** platform type (DEC-60); drives the map symbol. Absent ⇒ generic marker. */ + kind?: string, + /** optional per-asset symbol override (a glyph); absent ⇒ derived from kind + allegiance */ + symbol?: string, + /** intel reliability of this asset (DEC-19); rendered as marker emphasis + a roster badge */ + confidence?: string, + /** free-text strength descriptor (e.g. "×3", "platoon"); display-only */ + strength?: string, + /** free-text operator notes; display-only */ + notes?: string, +} + + +/** + * Own-force pool member (capability-matched ALLOCATION deferred to H2; display-only in v1 — does not drive routing). The capability vocabulary is the seam a future Scheme matches to a requirement's activity needs (DEC-59/60). + */ +export interface BlueParams { + /** "available | down" (own State mirror, DEC-52) */ + availability?: string, + /** capability tags a future Scheme matches to activity needs (stub) */ + capabilities?: string[], + /** mission-minute window the asset is available (display-only Sync-Matrix track) */ + availability_window?: TimeWindow, + /** free-text own-force role (e.g. recce, C2, fires); display-only */ + role?: string, +} + + +/** + * Hostile threat picture (threat SOURCE only in v1; reactive behaviour deferred, DEC-51). + */ +export interface RedParams { + /** graded threat severity (e.g. 1..5) */ + severity?: number, + /** mission-minute windows the threat is active (Sync-Matrix track) */ + active_windows?: TimeWindow[], + /** outer reach the threat can DETECT within (metres); the faint outer ring */ + detection_range_m?: number, + /** inner reach the threat can ENGAGE within (metres); the bold inner ring (≤ detection) */ + engagement_range_m?: number, + /** free-text threat / weapon type (e.g. SAM, MG, armour); display-only */ + threat_type?: string, +} + + +/** + * Neutral / collateral picture (ROE & collateral emission deferred; inert in v1, DEC-60 J3). + */ +export interface GreenParams { + /** graded collateral weight (e.g. 1..5) */ + sensitivity?: number, + /** nature of the rule (tagged for the future hard/soft split) */ + protection?: string, + /** kind of protected place (hospital / school / …); display-only */ + category?: string, +} + + /** * Every input that determines a plan, bundled into one identity (DEC-23/24/29): which requirement, which world, which config core, what appetites and steering, which kernel and seed. A plan's id IS the hash of its Stamp, so two plans are comparable only when their stamps share a basis (the comparability guard). Skeleton additions (DEC-47): `profile_version` and `start` are part of identity because the plan depends on the platform and the starting state. */ diff --git a/schema/orbat.yaml b/schema/orbat.yaml new file mode 100644 index 0000000..0373243 --- /dev/null +++ b/schema/orbat.yaml @@ -0,0 +1,143 @@ +# orbat.yaml — GENERATED MODULE of the REMIT data model. +# Part of schema/remit.yaml (the entry schema). Edit here, then run schema/generate.sh. + +id: https://deepbluecltd.github.io/REMIT/schema/remit/orbat +name: remit-orbat +title: REMIT — orbat +description: 'The ORBAT roster (DEC-60): the participants and potential participants of a scenario — own force (blue), hostile (red), neutral (green) — as first-class, allegiance-typed, independently-tunable Assets. Display-only authoring scaffolding in v1 (NF9 honest floor): no Asset drives kernel routing; the existing planned own-force is reconciled, not re-implemented.' +prefixes: + linkml: https://w3id.org/linkml/ + remit: https://deepbluecltd.github.io/REMIT/schema/remit/ +default_prefix: remit +default_range: string +imports: +- linkml:types +- common +- requirement +classes: + Orbat: + description: >- + The roster of participants & potential participants for a scenario — the authoring root + (DEC-60). Versioned & immutable when committed (lineage); the editable working draft mirrors + to localStorage. v1 authors the red & green sides; blue is the existing own force. + attributes: + id: + identifier: true + description: content id of the canonical form (DEC-35) + name: {} + version: + range: integer + assets: + range: Asset + multivalued: true + inlined_as_list: true + lineage: + range: Lineage + inlined: true + Asset: + description: >- + One ORBAT entry — a first-class located thing (DEC-52) typed by allegiance, with + independently-tunable parameters. Display-only in v1. + attributes: + id: + identifier: true + description: stable per-instance identity (not the label) + allegiance: + range: Allegiance + required: true + label: + description: human label; need not be unique + position: + range: Waypoint + inlined: true + description: AO location (H3 cell / lat-lon) + extent_m: + range: float + description: reach / footprint radius in metres + blue: + range: BlueParams + inlined: true + description: present iff allegiance = blue + red: + range: RedParams + inlined: true + description: present iff allegiance = red + green: + range: GreenParams + inlined: true + description: present iff allegiance = green + canonical_own_force: + range: boolean + description: >- + true on the single blue asset reconciled from the existing planned own-force (ROVER-1); + it drives the plan via the existing machinery and is protected from removal. + kind: + range: PlatformKind + description: platform type (DEC-60); drives the map symbol. Absent ⇒ generic marker. + symbol: + description: optional per-asset symbol override (a glyph); absent ⇒ derived from kind + allegiance + confidence: + range: ConfidenceLevel + description: intel reliability of this asset (DEC-19); rendered as marker emphasis + a roster badge + strength: + description: free-text strength descriptor (e.g. "×3", "platoon"); display-only + notes: + description: free-text operator notes; display-only + BlueParams: + description: >- + Own-force pool member (capability-matched ALLOCATION deferred to H2; display-only in v1 — does + not drive routing). The capability vocabulary is the seam a future Scheme matches to a + requirement's activity needs (DEC-59/60). + attributes: + availability: + range: string + description: '"available | down" (own State mirror, DEC-52)' + capabilities: + range: string + multivalued: true + description: capability tags a future Scheme matches to activity needs (stub) + availability_window: + range: TimeWindow + inlined: true + description: mission-minute window the asset is available (display-only Sync-Matrix track) + role: + description: free-text own-force role (e.g. recce, C2, fires); display-only + RedParams: + description: Hostile threat picture (threat SOURCE only in v1; reactive behaviour deferred, DEC-51). + attributes: + severity: + range: integer + description: graded threat severity (e.g. 1..5) + active_windows: + range: TimeWindow + multivalued: true + inlined_as_list: true + description: mission-minute windows the threat is active (Sync-Matrix track) + detection_range_m: + range: float + description: outer reach the threat can DETECT within (metres); the faint outer ring + engagement_range_m: + range: float + description: inner reach the threat can ENGAGE within (metres); the bold inner ring (≤ detection) + threat_type: + description: free-text threat / weapon type (e.g. SAM, MG, armour); display-only + GreenParams: + description: Neutral / collateral picture (ROE & collateral emission deferred; inert in v1, DEC-60 J3). + attributes: + sensitivity: + range: integer + description: graded collateral weight (e.g. 1..5) + protection: + range: Protection + description: nature of the rule (tagged for the future hard/soft split) + category: + range: GreenCategory + description: kind of protected place (hospital / school / …); display-only +enums: + Protection: + description: The nature of a green asset's protection rule (tagged for the future DEC-60 J3 hard/soft split). + permissible_values: + keep_out: + description: no-go / no-strike area (future HARD constraint) + minimise_effect: + description: collateral to be minimised (future SOFT objective) diff --git a/schema/remit.yaml b/schema/remit.yaml index 0f8ab32..edf2117 100644 --- a/schema/remit.yaml +++ b/schema/remit.yaml @@ -28,5 +28,6 @@ imports: - world - force - entities +- orbat - plan - records diff --git a/site/data-model/index.html b/site/data-model/index.html index 8c51772..28df6d1 100644 --- a/site/data-model/index.html +++ b/site/data-model/index.html @@ -90,10 +90,10 @@

    Generated reference · LinkML (DEC-57)

    REMIT — v1 Data Model

    The serialisable object core of REMIT's v1 mission-planning model — the shapes mission planning is built from. This LinkML schema is the Doc-owned source of truth (DEC-57): one schema generates JSON Schema, TypeScript types, and this hyperlinked reference + ER diagrams. `docs/remit-data-model.md` is the prose spine; the field names and structures here are reconciled against the objects the walking-skeleton app actually constructs (`app/js/**`), so the schema agrees with the running code.

    -
    7 modules64 classes -21 enumsJSON Schema & TypeScript generated alongside
    -
    -

    The model at a glance

    The serialisable object core, split into 7 modules.

    Whole-model ER diagram

    Every typed association across the modules, drawn in your browser (Mermaid). Scroll to zoom, drag to pan, click a box to jump to its class.

    Show the 64-class diagram
    erDiagram
    +
    7 modules69 classes +25 enumsJSON Schema & TypeScript generated alongside
    +
    +

    The model at a glance

    The serialisable object core, split into 7 modules.

    Whole-model ER diagram

    Every typed association across the modules, drawn in your browser (Mermaid). Scroll to zoom, drag to pan, click a box to jump to its class.

    Show the 69-class diagram
    erDiagram
       Attribution {
       }
       DataProvenance {
    @@ -168,6 +168,16 @@ 

    REMIT — v1 Data Model

    The serialisable object core of } Aspect { } + Orbat { + } + Asset { + } + BlueParams { + } + RedParams { + } + GreenParams { + } Stamp { } StartState { @@ -264,6 +274,14 @@

    REMIT — v1 Data Model

    The serialisable object core of Entity ||--o| DataProvenance : "provenance" Entity ||--o{ Aspect : "aspects" Aspect ||..o| Channel : "channel_ref" + Orbat ||--o{ Asset : "assets" + Orbat ||--o| Lineage : "lineage" + Asset ||--o| Waypoint : "position" + Asset ||--o| BlueParams : "blue" + Asset ||--o| RedParams : "red" + Asset ||--o| GreenParams : "green" + BlueParams ||--o| TimeWindow : "availability_window" + RedParams ||--o{ TimeWindow : "active_windows" Stamp ||..o| Requirement : "requirement_version" Stamp ||..o| Baseline : "baseline_version" Stamp ||..o{ Excursion : "excursions" @@ -414,7 +432,7 @@

    REMIT — v1 Data Model

    The serialisable object core of } Entity ||--o| DataProvenance : "provenance" Entity ||--o{ Aspect : "aspects" - Aspect ||..o| Channel : "channel_ref"

    contains (inlined)  ·  references (by id)  ·  o{ many  || one  o| optional. Boxes outside the module are classes defined elsewhere.

    Entity

    identified

    Anything located with an identity — yourself, an actor, a feature, a phenomenon (DEC-52). Where a Channel is a field, an Entity is a thing, projected across synchronised views. To affect the plan it is *cast* to a Channel or Commitment (actor → moving risk channel; window → timing commitment); in v1 entities are display-only. Skeleton note: aspects are keyed by name in an object (phase/fuel/height/pass); modelled here as a list of named Aspects.

    fieldtypecarddescription
    ididstring1e.g. "ent-self", "ent-tide", "ent-sat"
    labelstring0..1
    kindstring0..1conceptual kind (DEC-52) — one of: self, actor, feature, phenomenon. Optional; the skeleton folds this into provenance.kind. A documented string rather than an enum (see the AspectType note).
    provenanceDataProvenance0..1
    aspectsAspect0..*

    Aspect

    identified

    One facet of an entity, over time (DEC-52/53) — its position, an altitude, a timing window, an up/down status — rendered onto a shared axis by a view. Its value `f(t)` is behaviour, evaluated across the seam, and is not a data slot; the render-type, unit and bounds *are* data.

    fieldtypecarddescription
    nameidstring1e.g. "phase", "fuel", "height", "pass"
    typeAspectType1
    unitstring0..1unit for scalar aspects (e.g. "%")
    domainfloat0..*"[min, max] bounds for scalar aspects"
    channel_refChannel0..1the source channel, if any

    Plan 14 classes

    Plan identity and plans: Stamp, Plan and its materialisation, scores and conflicts (DEC-23/29).

    erDiagram
    +  Aspect ||..o| Channel : "channel_ref"

    contains (inlined)  ·  references (by id)  ·  o{ many  || one  o| optional. Boxes outside the module are classes defined elsewhere.

    Entity

    identified

    Anything located with an identity — yourself, an actor, a feature, a phenomenon (DEC-52). Where a Channel is a field, an Entity is a thing, projected across synchronised views. To affect the plan it is *cast* to a Channel or Commitment (actor → moving risk channel; window → timing commitment); in v1 entities are display-only. Skeleton note: aspects are keyed by name in an object (phase/fuel/height/pass); modelled here as a list of named Aspects.

    fieldtypecarddescription
    ididstring1e.g. "ent-self", "ent-tide", "ent-sat"
    labelstring0..1
    kindstring0..1conceptual kind (DEC-52) — one of: self, actor, feature, phenomenon. Optional; the skeleton folds this into provenance.kind. A documented string rather than an enum (see the AspectType note).
    provenanceDataProvenance0..1
    allegianceAllegiance0..1side typing (DEC-60); absent ⇒ unaligned / own-context as today
    aspectsAspect0..*

    Aspect

    identified

    One facet of an entity, over time (DEC-52/53) — its position, an altitude, a timing window, an up/down status — rendered onto a shared axis by a view. Its value `f(t)` is behaviour, evaluated across the seam, and is not a data slot; the render-type, unit and bounds *are* data.

    fieldtypecarddescription
    nameidstring1e.g. "phase", "fuel", "height", "pass"
    typeAspectType1
    unitstring0..1unit for scalar aspects (e.g. "%")
    domainfloat0..*"[min, max] bounds for scalar aspects"
    channel_refChannel0..1the source channel, if any

    Plan 14 classes

    Plan identity and plans: Stamp, Plan and its materialisation, scores and conflicts (DEC-23/29).

    erDiagram
       Stamp {
       }
       StartState {
    @@ -503,7 +521,7 @@ 

    REMIT — v1 Data Model

    The serialisable object core of Replan ||..o| Plan : "to" Replan ||..o| SelectionRationale : "rationale_ref" SteeringDelta ||--o{ Constraint : "constraints" - ExecutionDelta ||--o| HexCell : "cell"

    contains (inlined)  ·  references (by id)  ·  o{ many  || one  o| optional. Boxes outside the module are classes defined elsewhere.

    SelectionRationale

    identified

    The record of a choice (DEC-23): which plan won, which it beat, on which axis, with the after-mitigation bands, and who decided. Content-addressed.

    fieldtypecarddescription
    ididstring1content id of the canonical form (DEC-35)
    chosenPlan0..1the winning plan
    beatenPlan0..*the field it beat
    deciding_axisstring0..1"what tipped it — e.g. time/speed, exposure, robustness, completeness"
    notestring0..1the justification narrative
    appetitesAppetite0..*the risk-appetite selections at the time
    mitigationsstring0..*"e.g. armed-escort"
    chosen_bandsChosenBands0..1cost/robustness bands after mitigations
    bystring0..1
    rolestring0..1"e.g. implementer"
    atdatetime0..1

    ChosenBands

    fieldtypecarddescription
    costBand0..1
    robBand0..1

    ExecutionLog

    The append-only live history of a mission (DEC-23/25/26): alerts when a band is crossed, observations that update truth, waivers, and replans — never overwritten. Together with rationales and stamps it gives a complete after-action record with perfect replay (F1).

    fieldtypecarddescription
    entriesLogEntry0..*

    LogEntry

    abstract

    Base of the append-only execution log; the store stamps each with a `seq`.

    fieldtypecarddescription
    kindLogEntryKind1
    seqinteger0..10-indexed sequence, stamped on append
    atinteger0..1mission minutes

    Alert

    is a LogEntry

    A band crossing or hard-infeasibility raised during execution (E3).

    fieldtypecarddescription
    causeAlertCause0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    AlertCause

    fieldtypecarddescription
    typeAlertCauseType1
    commitmentstring0..1the phased commitment being tracked (e.g. "observe", "exfil")
    fromstring0..1the prior band state
    tostring0..1the new band state

    Observation

    is a LogEntry

    A fact delta appended to the baseline's fact layer (E5).

    fieldtypecarddescription
    fact_deltaFactDelta0..1
    sourcestring0..1"e.g. operator"
    confidencestring0..1"e.g. reported"
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    FactDelta

    fieldtypecarddescription
    notestring0..1
    tagstring0..1"e.g. track-state, sighting, weather"

    Waiver

    is a LogEntry

    A relaxed commitment, attributed (B3). Not yet logged in the v1 skeleton.

    fieldtypecarddescription
    commitment_idCommitment0..1
    bystring0..1
    authoritystring0..1
    rationalestring0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    Replan

    is a LogEntry

    A move from one committed plan to another, with rationale (DEC-36). Not yet logged in the v1 skeleton.

    fieldtypecarddescription
    fromPlan0..1
    toPlan0..1
    rationale_refSelectionRationale0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    Delta

    abstract

    A stamped cross-role write to the shared store (DEC-61): a role's contribution is an attributed delta — the same write path that serialises over `/sync` later (DEC-25), so going multi-node is transport, not re-architecture. Base of the concrete delta kinds; v1 emits SteeringDelta. Write-scope *enforcement* is designed-for — the delta is attributed (NF2) but not yet scope-checked.

    fieldtypecarddescription
    scopestring0..1"the write-scope this delta falls under — e.g. steering"
    bystring0..1the contributing author
    rolestring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atdatetime0..1when it was contributed (DEC-15, NF2)

    SteeringDelta

    is a Delta

    The first DEC-61 write built in v1: an operator's *applied intel* — denied (no-go) cells — shared to the store as steering constraints, where every surface (e.g. the Data Analysis monitor, including a popped-out one) sees it land. Risk appetites, by contrast, stay local (a ranking lens). Identity is content (DEC-35): the store keys it by the hash of its canonical form.

    fieldtypecarddescription
    constraintsConstraint0..*the interpreted steering gestures shared (DEC-24) — e.g. no-go regions
    scopeinheritedstring0..1"the write-scope this delta falls under — e.g. steering"
    byinheritedstring0..1the contributing author
    roleinheritedstring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atinheriteddatetime0..1when it was contributed (DEC-15, NF2)

    ExecutionDelta

    is a Delta

    An in-flight operator perturbation during Execute (issue #7): an obstruction (a +N min hold at the vehicle's current cell) or a blocked cell forcing a re-route. Previously these survived only as a prose Observation note; capturing them as a typed, content-addressed store object preserves their structured inputs for inspection (the Data Analysis monitor) and replay (NF3), and re-uses the DEC-61 attributed-delta write path. Identity is content (DEC-35).

    fieldtypecarddescription
    eventExecutionEventKind1which perturbation — obstruction or block
    at_mininteger0..1mission minutes when applied (sim-time ≡ plan-time, ADR-0007)
    cellHexCell0..1where the perturbation bites — the vehicle's cell for an obstruction, the blocked cell for a block
    delay_minfloat0..1the hold added by an obstruction (absent for a block)
    rv_minfloat0..1the re-planned RV / mission-end after the perturbation
    absorbed_minfloat0..1minutes the downstream holds absorbed, so the RV slipped less than delay_min (ADR-0007)
    scopeinheritedstring0..1"the write-scope this delta falls under — e.g. steering"
    byinheritedstring0..1the contributing author
    roleinheritedstring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atinheriteddatetime0..1when it was contributed (DEC-15, NF2)

    Enums 21 value sets

    The closed value vocabularies (in common) used by the fields above.

    Criticality

    enum

    Whether a commitment must hold or is best-effort (DEC-22).

    • hard — must hold
    • soft — best-effort; carries a priority, with authority from provenance

    ActivityType

    enum

    The verb of an activity (DEC-16); v1 set, pluggable registry (NF7).

    • visit
    • loiter
    • avoid
    • transit
    • maintain

    CommitmentState

    enum

    The lifecycle state of a commitment (B4). `at-risk` is live-only (E2) and not stored.

    • proposed
    • negotiated
    • committed
    • satisfied
    • violated
    • waived
    • superseded

    AnswerStatus

    enum

    • confirmed — answered by the operator
    • defaulted — filled from a stamped default

    AmbiguityStatus

    enum

    • open
    • resolved

    Domain

    enum

    The medium a baseline / profile / movement model operates in (DEC-20).

    • land
    • sea
    • air

    Terrain

    enum

    Per-cell land-cover class used by the skeleton's mobility raster.

    • road
    • track
    • open
    • rough
    • forest
    • marsh
    • ford
    • water

    Realisation

    enum

    How a channel or movement model is realised (DEC-49). `provider` = a computed service behind the seam.

    • raster
    • analytic
    • parametric
    • provider

    Predictability

    enum

    How predictable a channel is over time (DEC-21) — drives sampling.

    • static
    • periodic
    • dynamic

    ConfidenceLevel

    enum

    • high
    • medium
    • low

    AspectType

    enum

    The render-class of one time-varying facet of an entity (DEC-52/53).

    • cell — a position → map glyph / distance-along-track
    • scalar — a numeric value → a line (altitude, fuel, distance)
    • window — a time interval → a band
    • status — an up/down or phase value → ticks

    StrategyKey

    enum

    The skeleton's three candidate strategies (the "handful" of plans).

    • direct — fastest by time/speed
    • tracked — keeps to tracked/road surfaces
    • covered — favours exposure-reducing cover

    Band

    enum

    A coarse three-level qualitative band for cost / robustness (C2/C6, NF10).

    • robust
    • marginal
    • fragile

    MarginBand

    enum

    The slack band on a single commitment's satisfaction (A2, NF10). `crossed` = violated.

    • robust
    • marginal
    • tight
    • crossed

    Verdict

    enum

    A commitment's verdict in a plan's scores.

    • satisfied
    • violated
    • waived

    ScheduleLegKind

    enum

    The kind of one leg in a plan's schedule.

    • transit
    • hold — a wait — e.g. "await low-tide window" at a tidal ford
    • visit
    • exfil

    TideMode

    enum

    The outcome of the tidal-ford wait-vs-detour weighing (ADR-0006).

    • no-ford — no tidal ford on the route
    • open — ford open at the bank — cross now
    • wait — hold at the bank for the low-tide window, then cross
    • detour — ford-free detour reaches the RV sooner

    ConflictKind

    enum

    Whether a clash is built into the request or emerges from the schedule (C1).

    • structural — e.g. no route exists
    • emergent — e.g. the schedule is infeasible

    AlertCauseType

    enum

    Why the wingman raised an execution alert (E3).

    • hard_infeasible
    • band_crossing
    • tide_reassessment — a re-plan changed the tide decision's mode (ADR-0007)

    LogEntryKind

    enum

    • Alert
    • Observation
    • Waiver
    • Replan

    ExecutionEventKind

    enum

    The kind of in-flight operator perturbation applied during Execute (issue

    • obstruction — a +N min hold spliced at the vehicle's current cell, re-timed through the tide-aware chooser (ADR-0006/0007)
    • block — a cell ahead declared impassable, forcing an in-flight re-route around it
    + ExecutionDelta ||--o| HexCell : "cell"

    contains (inlined)  ·  references (by id)  ·  o{ many  || one  o| optional. Boxes outside the module are classes defined elsewhere.

    SelectionRationale

    identified

    The record of a choice (DEC-23): which plan won, which it beat, on which axis, with the after-mitigation bands, and who decided. Content-addressed.

    fieldtypecarddescription
    ididstring1content id of the canonical form (DEC-35)
    chosenPlan0..1the winning plan
    beatenPlan0..*the field it beat
    deciding_axisstring0..1"what tipped it — e.g. time/speed, exposure, robustness, completeness"
    notestring0..1the justification narrative
    appetitesAppetite0..*the risk-appetite selections at the time
    mitigationsstring0..*"e.g. armed-escort"
    chosen_bandsChosenBands0..1cost/robustness bands after mitigations
    bystring0..1
    rolestring0..1"e.g. implementer"
    atdatetime0..1

    ChosenBands

    fieldtypecarddescription
    costBand0..1
    robBand0..1

    ExecutionLog

    The append-only live history of a mission (DEC-23/25/26): alerts when a band is crossed, observations that update truth, waivers, and replans — never overwritten. Together with rationales and stamps it gives a complete after-action record with perfect replay (F1).

    fieldtypecarddescription
    entriesLogEntry0..*

    LogEntry

    abstract

    Base of the append-only execution log; the store stamps each with a `seq`.

    fieldtypecarddescription
    kindLogEntryKind1
    seqinteger0..10-indexed sequence, stamped on append
    atinteger0..1mission minutes

    Alert

    is a LogEntry

    A band crossing or hard-infeasibility raised during execution (E3).

    fieldtypecarddescription
    causeAlertCause0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    AlertCause

    fieldtypecarddescription
    typeAlertCauseType1
    commitmentstring0..1the phased commitment being tracked (e.g. "observe", "exfil")
    fromstring0..1the prior band state
    tostring0..1the new band state

    Observation

    is a LogEntry

    A fact delta appended to the baseline's fact layer (E5).

    fieldtypecarddescription
    fact_deltaFactDelta0..1
    sourcestring0..1"e.g. operator"
    confidencestring0..1"e.g. reported"
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    FactDelta

    fieldtypecarddescription
    notestring0..1
    tagstring0..1"e.g. track-state, sighting, weather"

    Waiver

    is a LogEntry

    A relaxed commitment, attributed (B3). Not yet logged in the v1 skeleton.

    fieldtypecarddescription
    commitment_idCommitment0..1
    bystring0..1
    authoritystring0..1
    rationalestring0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    Replan

    is a LogEntry

    A move from one committed plan to another, with rationale (DEC-36). Not yet logged in the v1 skeleton.

    fieldtypecarddescription
    fromPlan0..1
    toPlan0..1
    rationale_refSelectionRationale0..1
    kindinheritedLogEntryKind1
    seqinheritedinteger0..10-indexed sequence, stamped on append
    atinheritedinteger0..1mission minutes

    Delta

    abstract

    A stamped cross-role write to the shared store (DEC-61): a role's contribution is an attributed delta — the same write path that serialises over `/sync` later (DEC-25), so going multi-node is transport, not re-architecture. Base of the concrete delta kinds; v1 emits SteeringDelta. Write-scope *enforcement* is designed-for — the delta is attributed (NF2) but not yet scope-checked.

    fieldtypecarddescription
    scopestring0..1"the write-scope this delta falls under — e.g. steering"
    bystring0..1the contributing author
    rolestring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atdatetime0..1when it was contributed (DEC-15, NF2)

    SteeringDelta

    is a Delta

    The first DEC-61 write built in v1: an operator's *applied intel* — denied (no-go) cells — shared to the store as steering constraints, where every surface (e.g. the Data Analysis monitor, including a popped-out one) sees it land. Risk appetites, by contrast, stay local (a ranking lens). Identity is content (DEC-35): the store keys it by the hash of its canonical form.

    fieldtypecarddescription
    constraintsConstraint0..*the interpreted steering gestures shared (DEC-24) — e.g. no-go regions
    scopeinheritedstring0..1"the write-scope this delta falls under — e.g. steering"
    byinheritedstring0..1the contributing author
    roleinheritedstring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atinheriteddatetime0..1when it was contributed (DEC-15, NF2)

    ExecutionDelta

    is a Delta

    An in-flight operator perturbation during Execute (issue #7): an obstruction (a +N min hold at the vehicle's current cell) or a blocked cell forcing a re-route. Previously these survived only as a prose Observation note; capturing them as a typed, content-addressed store object preserves their structured inputs for inspection (the Data Analysis monitor) and replay (NF3), and re-uses the DEC-61 attributed-delta write path. Identity is content (DEC-35).

    fieldtypecarddescription
    eventExecutionEventKind1which perturbation — obstruction or block
    at_mininteger0..1mission minutes when applied (sim-time ≡ plan-time, ADR-0007)
    cellHexCell0..1where the perturbation bites — the vehicle's cell for an obstruction, the blocked cell for a block
    delay_minfloat0..1the hold added by an obstruction (absent for a block)
    rv_minfloat0..1the re-planned RV / mission-end after the perturbation
    absorbed_minfloat0..1minutes the downstream holds absorbed, so the RV slipped less than delay_min (ADR-0007)
    scopeinheritedstring0..1"the write-scope this delta falls under — e.g. steering"
    byinheritedstring0..1the contributing author
    roleinheritedstring0..1"the contributing role-hat — e.g. duty-officer-plans"
    atinheriteddatetime0..1when it was contributed (DEC-15, NF2)

    Enums 25 value sets

    The closed value vocabularies (in common) used by the fields above.

    Criticality

    enum

    Whether a commitment must hold or is best-effort (DEC-22).

    • hard — must hold
    • soft — best-effort; carries a priority, with authority from provenance

    ActivityType

    enum

    The verb of an activity (DEC-16); v1 set, pluggable registry (NF7).

    • visit
    • loiter
    • avoid
    • transit
    • maintain

    CommitmentState

    enum

    The lifecycle state of a commitment (B4). `at-risk` is live-only (E2) and not stored.

    • proposed
    • negotiated
    • committed
    • satisfied
    • violated
    • waived
    • superseded

    AnswerStatus

    enum

    • confirmed — answered by the operator
    • defaulted — filled from a stamped default

    AmbiguityStatus

    enum

    • open
    • resolved

    Domain

    enum

    The medium a baseline / profile / movement model operates in (DEC-20).

    • land
    • sea
    • air

    Terrain

    enum

    Per-cell land-cover class used by the skeleton's mobility raster.

    • road
    • track
    • open
    • rough
    • forest
    • marsh
    • ford
    • water

    Realisation

    enum

    How a channel or movement model is realised (DEC-49). `provider` = a computed service behind the seam.

    • raster
    • analytic
    • parametric
    • provider

    Predictability

    enum

    How predictable a channel is over time (DEC-21) — drives sampling.

    • static
    • periodic
    • dynamic

    ConfidenceLevel

    enum

    • high
    • medium
    • low

    Allegiance

    enum

    Side typing on an entity (DEC-60). Selects the kernel STANCE — plan-for (blue) / avoid-assess (red) / respect (green). v1 is display-only (NF9 honest floor).

    • blue — own force
    • red — hostile / adversary (threat source; passive in v1)
    • green — neutral / host-nation / civilian (ROE & collateral; inert in v1)

    PlatformKind

    enum

    The platform/type class of an ORBAT asset (DEC-60). Orthogonal to allegiance; drives the map symbol. Display-only in v1 (NF9). v1 set, pluggable like ActivityType (NF7).

    • infantry — dismounted personnel
    • vehicle — ground vehicle
    • aircraft — fixed / rotary wing
    • vessel — surface / sub-surface craft
    • sensor — sensor / radar / observation post
    • emplacement — fixed weapon / SAM / fortified position
    • structure — building / installation / facility

    GreenCategory

    enum

    The category of a neutral / green protected place (DEC-60 J3); display-only in v1.

    • hospital — medical facility
    • school — educational facility
    • utility — power / water / comms infrastructure
    • place_of_worship — religious site
    • residential — populated residential area
    • other — uncategorised protected place

    AspectType

    enum

    The render-class of one time-varying facet of an entity (DEC-52/53).

    • cell — a position → map glyph / distance-along-track
    • scalar — a numeric value → a line (altitude, fuel, distance)
    • window — a time interval → a band
    • status — an up/down or phase value → ticks

    StrategyKey

    enum

    The skeleton's three candidate strategies (the "handful" of plans).

    • direct — fastest by time/speed
    • tracked — keeps to tracked/road surfaces
    • covered — favours exposure-reducing cover

    Band

    enum

    A coarse three-level qualitative band for cost / robustness (C2/C6, NF10).

    • robust
    • marginal
    • fragile

    MarginBand

    enum

    The slack band on a single commitment's satisfaction (A2, NF10). `crossed` = violated.

    • robust
    • marginal
    • tight
    • crossed

    Verdict

    enum

    A commitment's verdict in a plan's scores.

    • satisfied
    • violated
    • waived

    ScheduleLegKind

    enum

    The kind of one leg in a plan's schedule.

    • transit
    • hold — a wait — e.g. "await low-tide window" at a tidal ford
    • visit
    • exfil

    TideMode

    enum

    The outcome of the tidal-ford wait-vs-detour weighing (ADR-0006).

    • no-ford — no tidal ford on the route
    • open — ford open at the bank — cross now
    • wait — hold at the bank for the low-tide window, then cross
    • detour — ford-free detour reaches the RV sooner

    ConflictKind

    enum

    Whether a clash is built into the request or emerges from the schedule (C1).

    • structural — e.g. no route exists
    • emergent — e.g. the schedule is infeasible

    AlertCauseType

    enum

    Why the wingman raised an execution alert (E3).

    • hard_infeasible
    • band_crossing
    • tide_reassessment — a re-plan changed the tide decision's mode (ADR-0007)

    LogEntryKind

    enum

    • Alert
    • Observation
    • Waiver
    • Replan

    ExecutionEventKind

    enum

    The kind of in-flight operator perturbation applied during Execute (issue

    • obstruction — a +N min hold spliced at the vehicle's current cell, re-timed through the tide-aware chooser (ADR-0006/0007)
    • block — a cell ahead declared impassable, forcing an in-flight re-route around it

    Protection

    enum

    The nature of a green asset's protection rule (tagged for the future DEC-60 J3 hard/soft split).

    • keep_out — no-go / no-strike area (future HARD constraint)
    • minimise_effect — collateral to be minimised (future SOFT objective)
    diff --git a/specs/004-orbat-red-green-assets/blog/post.md b/specs/004-orbat-red-green-assets/blog/post.md new file mode 100644 index 0000000..1a0d37e --- /dev/null +++ b/specs/004-orbat-red-green-assets/blog/post.md @@ -0,0 +1,85 @@ +--- +layout: default +title: "The ORBAT grows all three sides" +--- + +> **At a glance —** the ORBAT now carries all three sides: drop in as many own-force, threat +> and protected-place assets as a scenario needs, tune each one, and watch them appear on the +> map and the Sync Matrix — without a single one touching the route. + +![Red threat assets placed and tuned on the Solway map](screenshots/hero.png) + +## The problem + +REMIT's entity catalogue was fixed in config and seeded only a *single* own force. A planner +could describe where their vehicle started — and nothing else. There was no way to express the +own-force **pool**, the **adversary**, or the **neutral** picture (the no-strike places, the +civilian sensitivities) that every real scenario carries. The whole left-hand side of the +order of battle was simply missing, and the serialisable shape for it had to live *somewhere* +defensible. + +## Options + +- **A new object family vs. reusing Entity.** We could mint bespoke `ThreatMarker` / `RoeZone` + types — or treat every asset as a first-class **Entity** (DEC-52) that simply carries an + `allegiance`. A parallel family would duplicate the map / Sync-Matrix projection machinery + and break the "one ontology, three stances" symmetry of DEC-60. +- **Free-form map drawing vs. a tunable roster.** A draw-on-the-map surface is fun but + illegible past a handful of assets; a grouped **roster with per-instance tuners** stays + readable at ten-plus per side. +- **Where the data lives — hand-written vs. generated.** The persisted ORBAT is serialisable + object-core, so it is exactly what LinkML owns (Principle I). Hand-authoring the shape in + `app/js` would be the re-listing anti-pattern. +- **Blue: display-only vs. wired into the planner.** Should authoring a blue pool asset change + routing? Not in v1 — the honest floor (NF9) forbids fabricated behaviour, and reactive + adversaries / capability-matched allocation are deferred capabilities. + +## The strategy + +Reuse **Entity + allegiance** and **schema-define the shape, then regenerate**. We added an +`Allegiance` enum (`blue|red|green`) and an optional `allegiance` attribute to `Entity`, plus a +new `schema/orbat.yaml` module (`Orbat`, `Asset`, `Blue/Red/GreenParams`, a `Protection` enum), +reusing the existing `Waypoint` / `Lineage` / `TimeWindow`. `bash schema/generate.sh` re-emits +the JSON Schema, the TypeScript types and the HTML reference; the app imports the generated +types and never re-lists them. + +All three sides are **display-only authoring scaffolding** under the DEC-56 horizon split and +the NF9 honest floor. The model module (`app/js/orbat/orbat.js`) is a pure, deterministic +writer — `add` / `duplicate` / `tune` / `remove` / `validate` / `canonical` / `commit` — and the +authoring panel (`app/js/shell/orbat-panel.js`, the **SME-Int** role-tab) and the map read +through it. Crucially, **blue does not drive routing**: the existing planned own-force (ROVER-1) +is *reconciled* as the single `canonical_own_force` blue asset — protected from removal — and +keeps driving the plan via the pre-existing machinery. Everything projects through the existing +entity → map / Sync-Matrix path rather than a parallel renderer. + +## The results + +- **Add / duplicate / tune / remove** multiple independent instances of each allegiance, each an + allegiance-coloured map marker (point + faint extent ring + label): blue `#4493f8`, red + `#ff7b72`, green `#38d39f`. +- **Sync-Matrix tracks** for time-windowed assets (a red patrol window, a blue availability + window), aligned with the shared playhead; selecting a row highlights it in both views. +- **Persistence:** the working draft mirrors to `localStorage` and survives reload exactly; + committing mints an immutable, content-addressed `Orbat` with lineage. +- **The invariants hold, and the tests prove it:** tuning *anything* — including a blue asset — + leaves the committed Plan ids byte-for-byte identical (display-only, NF9), and equal rosters + canonicalise to equal bytes regardless of insertion order (NF3). **11 new unit tests** and + **5 new e2e tests** (one per user story) are green, alongside the existing suites. + +## Screenshots + +The authoring panel — three allegiance groups, the canonical own-force surfaced and protected: + +![The ORBAT authoring panel with the three allegiance groups](screenshots/roster-panel.png) + +Two red threat assets placed and tuned (extent + severity) on the Solway map: + +![Two red assets with extent rings on the map](screenshots/map-red-assets.png) + +Green neutral assets, styled distinctly from red and own force: + +![Green neutral assets on the map](screenshots/map-green-assets.png) + +A red asset with an active window projects as a Sync-Matrix track, synchronised with the playhead: + +![A red asset's active window as a Sync-Matrix track](screenshots/sync-matrix-track.png) diff --git a/specs/004-orbat-red-green-assets/blog/screenshots/hero.png b/specs/004-orbat-red-green-assets/blog/screenshots/hero.png new file mode 100644 index 0000000..63dabd5 Binary files /dev/null and b/specs/004-orbat-red-green-assets/blog/screenshots/hero.png differ diff --git a/specs/004-orbat-red-green-assets/blog/screenshots/map-green-assets.png b/specs/004-orbat-red-green-assets/blog/screenshots/map-green-assets.png new file mode 100644 index 0000000..0c0429f Binary files /dev/null and b/specs/004-orbat-red-green-assets/blog/screenshots/map-green-assets.png differ diff --git a/specs/004-orbat-red-green-assets/blog/screenshots/map-red-assets.png b/specs/004-orbat-red-green-assets/blog/screenshots/map-red-assets.png new file mode 100644 index 0000000..63dabd5 Binary files /dev/null and b/specs/004-orbat-red-green-assets/blog/screenshots/map-red-assets.png differ diff --git a/specs/004-orbat-red-green-assets/blog/screenshots/roster-panel.png b/specs/004-orbat-red-green-assets/blog/screenshots/roster-panel.png new file mode 100644 index 0000000..033810b Binary files /dev/null and b/specs/004-orbat-red-green-assets/blog/screenshots/roster-panel.png differ diff --git a/specs/004-orbat-red-green-assets/blog/screenshots/sync-matrix-track.png b/specs/004-orbat-red-green-assets/blog/screenshots/sync-matrix-track.png new file mode 100644 index 0000000..637cd45 Binary files /dev/null and b/specs/004-orbat-red-green-assets/blog/screenshots/sync-matrix-track.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/01-empty-roster.png b/specs/004-orbat-red-green-assets/evidence/screenshots/01-empty-roster.png new file mode 100644 index 0000000..033810b Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/01-empty-roster.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/02-two-red-tuned.png b/specs/004-orbat-red-green-assets/evidence/screenshots/02-two-red-tuned.png new file mode 100644 index 0000000..b3c8b2d Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/02-two-red-tuned.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/03-green-assets.png b/specs/004-orbat-red-green-assets/evidence/screenshots/03-green-assets.png new file mode 100644 index 0000000..0c0429f Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/03-green-assets.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/04-blue-pool-route-unchanged.png b/specs/004-orbat-red-green-assets/evidence/screenshots/04-blue-pool-route-unchanged.png new file mode 100644 index 0000000..b6edcb3 Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/04-blue-pool-route-unchanged.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/05-roster-managed.png b/specs/004-orbat-red-green-assets/evidence/screenshots/05-roster-managed.png new file mode 100644 index 0000000..f43a92f Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/05-roster-managed.png differ diff --git a/specs/004-orbat-red-green-assets/evidence/screenshots/06-sync-matrix-track.png b/specs/004-orbat-red-green-assets/evidence/screenshots/06-sync-matrix-track.png new file mode 100644 index 0000000..637cd45 Binary files /dev/null and b/specs/004-orbat-red-green-assets/evidence/screenshots/06-sync-matrix-track.png differ diff --git a/specs/004-orbat-red-green-assets/tasks.md b/specs/004-orbat-red-green-assets/tasks.md index e96c09a..b09f375 100644 --- a/specs/004-orbat-red-green-assets/tasks.md +++ b/specs/004-orbat-red-green-assets/tasks.md @@ -28,12 +28,12 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes **Purpose**: Extend the LinkML data model (Principle I — source of truth) and scaffold the new module. Regenerated artefacts are outputs; never hand-edited. -- [ ] T001 Add `Allegiance` enum (`blue|red|green`, with stance notes) to `schema/common.yaml` -- [ ] T002 Add optional `allegiance` attribute (range `Allegiance`) to `Entity` in `schema/entities.yaml` -- [ ] T003 Create `schema/orbat.yaml` module — `Orbat`, `Asset` (+ `canonical_own_force`), `BlueParams`, `RedParams`, `GreenParams`, `TimeWindow` classes + `Protection` enum (per [data-model.md](./data-model.md)); reuse existing `Waypoint`/`Lineage` -- [ ] T004 Import the `orbat` module in the entry schema `schema/remit.yaml` -- [ ] T005 Regenerate artefacts: `bash schema/generate.sh` → verify `schema/gen/remit.schema.json` + `schema/gen/remit.ts` include `Orbat`/`Asset`/`Allegiance`/`BlueParams`; do NOT hand-edit generated files -- [ ] T006 [P] Create `app/js/orbat/orbat.js` skeleton (`// @ts-check`) importing the generated types from `schema/gen/remit.ts` +- [X] T001 Add `Allegiance` enum (`blue|red|green`, with stance notes) to `schema/common.yaml` +- [X] T002 Add optional `allegiance` attribute (range `Allegiance`) to `Entity` in `schema/entities.yaml` +- [X] T003 Create `schema/orbat.yaml` module — `Orbat`, `Asset` (+ `canonical_own_force`), `BlueParams`, `RedParams`, `GreenParams`, `TimeWindow` classes + `Protection` enum (per [data-model.md](./data-model.md)); reuse existing `Waypoint`/`Lineage` +- [X] T004 Import the `orbat` module in the entry schema `schema/remit.yaml` +- [X] T005 Regenerate artefacts: `bash schema/generate.sh` → verify `schema/gen/remit.schema.json` + `schema/gen/remit.ts` include `Orbat`/`Asset`/`Allegiance`/`BlueParams`; do NOT hand-edit generated files +- [X] T006 [P] Create `app/js/orbat/orbat.js` skeleton (`// @ts-check`) importing the generated types from `schema/gen/remit.ts` **Checkpoint**: schema regenerated, allegiance/asset shapes available as generated types. @@ -45,15 +45,15 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes **⚠️ CRITICAL**: No user-story work begins until this phase is complete. -- [ ] T007 Implement `emptyOrbat(name)` + `canonical(orbat)` (assets sorted by `id`, canonical JSON, DEC-35) in `app/js/orbat/orbat.js` -- [ ] T008 Implement `validate(asset)` — bounds clamp for `extent_m`/`severity`/`sensitivity`, `start_min ≤ end_min`, position-in-AO, allegiance↔param-group match for all three allegiances — in `app/js/orbat/orbat.js` -- [ ] T009 Implement persistence `saveDraft`/`loadDraft` (localStorage key `remit.orbat.M-001`, canonical JSON) in `app/js/orbat/orbat.js` -- [ ] T010 Implement display-only `assetToEntity(asset)` adapter (allegiance-typed Entity; `provenance.kind='self'` for the canonical own-force else `'actor'`; position; no kernel reference — NF9) in `app/js/orbat/orbat.js` -- [ ] T011 Implement `reconcileOwnForce(orbat, self)` — surface the existing planned own-force (ROVER-1) as the single `canonical_own_force` blue asset, idempotent — in `app/js/orbat/orbat.js` -- [ ] T012 Extend `buildEntities()` to fold authored ORBAT assets into the entity set in `app/js/entities/entities.js` -- [ ] T013 Extend `map.render` to draw an allegiance-coloured asset marker layer (point + faint extent ring + label) in `app/js/views/map.js` -- [ ] T014 Register the ORBAT authoring panel as a config-declared role-tab (home: `sme-int`) in `app/js/shell/roles.js` and scaffold `app/js/shell/orbat-panel.js` with `mount(container, ctx)` -- [ ] T015 Wire the panel + authored assets into the render loop (feed assets to `map.render` and `buildEntities`; reconcile own-force on load) in `app/js/main.js` +- [X] T007 Implement `emptyOrbat(name)` + `canonical(orbat)` (assets sorted by `id`, canonical JSON, DEC-35) in `app/js/orbat/orbat.js` +- [X] T008 Implement `validate(asset)` — bounds clamp for `extent_m`/`severity`/`sensitivity`, `start_min ≤ end_min`, position-in-AO, allegiance↔param-group match for all three allegiances — in `app/js/orbat/orbat.js` +- [X] T009 Implement persistence `saveDraft`/`loadDraft` (localStorage key `remit.orbat.M-001`, canonical JSON) in `app/js/orbat/orbat.js` +- [X] T010 Implement display-only `assetToEntity(asset)` adapter (allegiance-typed Entity; `provenance.kind='self'` for the canonical own-force else `'actor'`; position; no kernel reference — NF9) in `app/js/orbat/orbat.js` +- [X] T011 Implement `reconcileOwnForce(orbat, self)` — surface the existing planned own-force (ROVER-1) as the single `canonical_own_force` blue asset, idempotent — in `app/js/orbat/orbat.js` +- [X] T012 Extend `buildEntities()` to fold authored ORBAT assets into the entity set in `app/js/entities/entities.js` +- [X] T013 Extend `map.render` to draw an allegiance-coloured asset marker layer (point + faint extent ring + label) in `app/js/views/map.js` +- [X] T014 Register the ORBAT authoring panel as a config-declared role-tab (home: `sme-int`) in `app/js/shell/roles.js` and scaffold `app/js/shell/orbat-panel.js` with `mount(container, ctx)` +- [X] T015 Wire the panel + authored assets into the render loop (feed assets to `map.render` and `buildEntities`; reconcile own-force on load) in `app/js/main.js` **Checkpoint**: an asset added in code renders on the map; the panel mounts; ROVER-1 shows as the canonical blue asset. Stories can now begin. @@ -67,16 +67,16 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ### Tests for User Story 1 -- [ ] T016 [P] [US1] Unit tests — deterministic `addAsset` (fresh unique id), `tuneAsset` red clamp (`extent_m`, `severity`), per-asset isolation, `canonical` stability — in `test/orbat.test.mjs` -- [ ] T017 [P] [US1] e2e — add two red assets, tune one, assert both visible + isolation + no fabricated adversary motion (honest floor) — in `e2e/orbat.spec.ts` +- [X] T016 [P] [US1] Unit tests — deterministic `addAsset` (fresh unique id), `tuneAsset` red clamp (`extent_m`, `severity`), per-asset isolation, `canonical` stability — in `test/orbat.test.mjs` +- [X] T017 [P] [US1] e2e — add two red assets, tune one, assert both visible + isolation + no fabricated adversary motion (honest floor) — in `e2e/orbat.spec.ts` ### Implementation for User Story 1 -- [ ] T018 [US1] Implement `addAsset(orbat, {allegiance:'red', position})` with red defaults + fresh id; reject out-of-AO positions — in `app/js/orbat/orbat.js` -- [ ] T019 [US1] Implement `tuneAsset(orbat, id, patch)` applying clamped `RedParams` (severity) + `extent_m`/`label` to only the targeted asset — in `app/js/orbat/orbat.js` -- [ ] T020 [US1] Render the **Red (hostile)** roster group, **Add red**, and per-row tuners (label, extent, severity) in `app/js/shell/orbat-panel.js` -- [ ] T021 [US1] Red marker styling (`#ff7b72` family) + extent ring + click-to-pick-hex placement in `app/js/views/map.js` -- [ ] T022 [US1] Inline validation feedback (clamp/reject with message) in `app/js/shell/orbat-panel.js`; capture evidence screenshots → `specs/004-orbat-red-green-assets/evidence/screenshots/` +- [X] T018 [US1] Implement `addAsset(orbat, {allegiance:'red', position})` with red defaults + fresh id; reject out-of-AO positions — in `app/js/orbat/orbat.js` +- [X] T019 [US1] Implement `tuneAsset(orbat, id, patch)` applying clamped `RedParams` (severity) + `extent_m`/`label` to only the targeted asset — in `app/js/orbat/orbat.js` +- [X] T020 [US1] Render the **Red (hostile)** roster group, **Add red**, and per-row tuners (label, extent, severity) in `app/js/shell/orbat-panel.js` +- [X] T021 [US1] Red marker styling (`#ff7b72` family) + extent ring + click-to-pick-hex placement in `app/js/views/map.js` +- [X] T022 [US1] Inline validation feedback (clamp/reject with message) in `app/js/shell/orbat-panel.js`; capture evidence screenshots → `specs/004-orbat-red-green-assets/evidence/screenshots/` **Checkpoint**: red ORBAT is authorable, tunable, and visible — the MVP. @@ -90,14 +90,14 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ### Tests for User Story 2 -- [ ] T023 [P] [US2] Unit tests — green defaults, `GreenParams` clamp (`sensitivity`, `protection`), isolation — in `test/orbat.test.mjs` -- [ ] T024 [P] [US2] e2e — add two green assets, tune one, assert distinct green styling vs red/own-force — in `e2e/orbat.spec.ts` +- [X] T023 [P] [US2] Unit tests — green defaults, `GreenParams` clamp (`sensitivity`, `protection`), isolation — in `test/orbat.test.mjs` +- [X] T024 [P] [US2] e2e — add two green assets, tune one, assert distinct green styling vs red/own-force — in `e2e/orbat.spec.ts` ### Implementation for User Story 2 -- [ ] T025 [US2] Extend `addAsset`/`tuneAsset` with green defaults + `GreenParams` (`sensitivity`, `protection`) in `app/js/orbat/orbat.js` -- [ ] T026 [US2] Render the **Green (neutral)** roster group, **Add green**, and per-row tuners (label, extent, sensitivity, protection) in `app/js/shell/orbat-panel.js` -- [ ] T027 [US2] Green marker styling (`#38d39f` family) visually distinct from red and own-force in `app/js/views/map.js` +- [X] T025 [US2] Extend `addAsset`/`tuneAsset` with green defaults + `GreenParams` (`sensitivity`, `protection`) in `app/js/orbat/orbat.js` +- [X] T026 [US2] Render the **Green (neutral)** roster group, **Add green**, and per-row tuners (label, extent, sensitivity, protection) in `app/js/shell/orbat-panel.js` +- [X] T027 [US2] Green marker styling (`#38d39f` family) visually distinct from red and own-force in `app/js/views/map.js` **Checkpoint**: red and green sides are independently authorable and visible. @@ -111,14 +111,14 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ### Tests for User Story 3 -- [ ] T028 [P] [US3] Unit tests — blue defaults + `BlueParams` clamp, `reconcileOwnForce` idempotent + single `canonical_own_force`, duplicate never copies the canonical flag — in `test/orbat.test.mjs` -- [ ] T029 [P] [US3] e2e — add + tune blue, assert blue styling + **route/plan unchanged** by tuning (display-only proof) + ROVER-1 shown reconciled — in `e2e/orbat.spec.ts` +- [X] T028 [P] [US3] Unit tests — blue defaults + `BlueParams` clamp, `reconcileOwnForce` idempotent + single `canonical_own_force`, duplicate never copies the canonical flag — in `test/orbat.test.mjs` +- [X] T029 [P] [US3] e2e — add + tune blue, assert blue styling + **route/plan unchanged** by tuning (display-only proof) + ROVER-1 shown reconciled — in `e2e/orbat.spec.ts` ### Implementation for User Story 3 -- [ ] T030 [US3] Extend `addAsset`/`tuneAsset` with blue defaults + `BlueParams` (`availability`, `capabilities`) in `app/js/orbat/orbat.js` -- [ ] T031 [US3] Render the **Blue (own force)** roster group, **Add blue**, per-row tuners (label, extent, availability, capabilities), and show the canonical own-force row marked with its **remove disabled** in `app/js/shell/orbat-panel.js` -- [ ] T032 [US3] Blue marker styling distinct from red/green in `app/js/views/map.js`; assert in the e2e/honest-floor path that a blue tune leaves the route unchanged (no kernel coupling) +- [X] T030 [US3] Extend `addAsset`/`tuneAsset` with blue defaults + `BlueParams` (`availability`, `capabilities`) in `app/js/orbat/orbat.js` +- [X] T031 [US3] Render the **Blue (own force)** roster group, **Add blue**, per-row tuners (label, extent, availability, capabilities), and show the canonical own-force row marked with its **remove disabled** in `app/js/shell/orbat-panel.js` +- [X] T032 [US3] Blue marker styling distinct from red/green in `app/js/views/map.js`; assert in the e2e/honest-floor path that a blue tune leaves the route unchanged (no kernel coupling) **Checkpoint**: all three allegiances are independently authorable and visible; blue stays display-only. @@ -132,14 +132,14 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ### Tests for User Story 4 -- [ ] T033 [P] [US4] Unit tests — `duplicateAsset` (new id, independent copy, no canonical flag), `removeAsset` (others unaffected; canonical own-force protected), `commit` immutability + lineage — in `test/orbat.test.mjs` -- [ ] T034 [P] [US4] e2e — duplicate + remove, reload page, assert roster + tuned values restored; canonical own-force not removable — in `e2e/orbat.spec.ts` +- [X] T033 [P] [US4] Unit tests — `duplicateAsset` (new id, independent copy, no canonical flag), `removeAsset` (others unaffected; canonical own-force protected), `commit` immutability + lineage — in `test/orbat.test.mjs` +- [X] T034 [P] [US4] e2e — duplicate + remove, reload page, assert roster + tuned values restored; canonical own-force not removable — in `e2e/orbat.spec.ts` ### Implementation for User Story 4 -- [ ] T035 [US4] Implement `duplicateAsset(orbat, id)` (deep-copy under new id, drop canonical flag) + `removeAsset(orbat, id)` (refuse the canonical own-force) in `app/js/orbat/orbat.js`; wire duplicate/remove controls in `app/js/shell/orbat-panel.js` -- [ ] T036 [US4] Mirror `saveDraft` on every mutating op and `loadDraft` on panel mount in `app/js/shell/orbat-panel.js` -- [ ] T037 [US4] Implement `commit(orbat, objects)` — immutable content-addressed `Orbat` version with `lineage.previous_version` — in `app/js/orbat/orbat.js`; add a **Commit ORBAT** action to the panel +- [X] T035 [US4] Implement `duplicateAsset(orbat, id)` (deep-copy under new id, drop canonical flag) + `removeAsset(orbat, id)` (refuse the canonical own-force) in `app/js/orbat/orbat.js`; wire duplicate/remove controls in `app/js/shell/orbat-panel.js` +- [X] T036 [US4] Mirror `saveDraft` on every mutating op and `loadDraft` on panel mount in `app/js/shell/orbat-panel.js` +- [X] T037 [US4] Implement `commit(orbat, objects)` — immutable content-addressed `Orbat` version with `lineage.previous_version` — in `app/js/orbat/orbat.js`; add a **Commit ORBAT** action to the panel **Checkpoint**: roster management + persistence across sessions work. @@ -153,13 +153,13 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ### Tests for User Story 5 -- [ ] T038 [P] [US5] e2e — asset with `active_windows` shows a Sync-Matrix track aligned with the playhead; selection syncs across views — in `e2e/orbat.spec.ts` +- [X] T038 [P] [US5] e2e — asset with `active_windows` shows a Sync-Matrix track aligned with the playhead; selection syncs across views — in `e2e/orbat.spec.ts` ### Implementation for User Story 5 -- [ ] T039 [US5] Add an `active_windows` (`TimeWindow[]`) tuner for red assets and an availability-window tuner for blue assets in `app/js/shell/orbat-panel.js` -- [ ] T040 [US5] Emit a `window`-type aspect from `assetToEntity` (reuse the satellite-pass render path) and add the catalogue row in `app/js/entities/entities.js` so the asset appears as a Sync-Matrix track -- [ ] T041 [US5] Bind shared selection across panel ↔ map ↔ Sync-Matrix in `app/js/main.js` +- [X] T039 [US5] Add an `active_windows` (`TimeWindow[]`) tuner for red assets and an availability-window tuner for blue assets in `app/js/shell/orbat-panel.js` +- [X] T040 [US5] Emit a `window`-type aspect from `assetToEntity` (reuse the satellite-pass render path) and add the catalogue row in `app/js/entities/entities.js` so the asset appears as a Sync-Matrix track +- [X] T041 [US5] Bind shared selection across panel ↔ map ↔ Sync-Matrix in `app/js/main.js` **Checkpoint**: all five user stories are independently functional. @@ -167,10 +167,10 @@ Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `tes ## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T042 [P] Record project memory: new ADR (ORBAT blue/red/green authoring scaffolding, blue display-only) in `docs/project_notes/decisions.md`; allegiance palette + localStorage key in `docs/project_notes/key_facts.md`; work-log entry in `docs/project_notes/issues.md` -- [ ] T043 [P] Author the blog post `specs/004-orbat-red-green-assets/blog/post.md` from `docs/blog-post-template.md` (problem/options/strategy/results/screenshots + "at a glance") and add `blog/screenshots/` -- [ ] T044 Run `quickstart.md` end-to-end and collect evidence screenshots into `specs/004-orbat-red-green-assets/evidence/screenshots/` -- [ ] T045 Run `npm run test:unit` + `npm run test:e2e`; ensure `// @ts-check` is clean across the new modules +- [X] T042 [P] Record project memory: new ADR (ORBAT blue/red/green authoring scaffolding, blue display-only) in `docs/project_notes/decisions.md`; allegiance palette + localStorage key in `docs/project_notes/key_facts.md`; work-log entry in `docs/project_notes/issues.md` +- [X] T043 [P] Author the blog post `specs/004-orbat-red-green-assets/blog/post.md` from `docs/blog-post-template.md` (problem/options/strategy/results/screenshots + "at a glance") and add `blog/screenshots/` +- [X] T044 Run `quickstart.md` end-to-end and collect evidence screenshots into `specs/004-orbat-red-green-assets/evidence/screenshots/` +- [X] T045 Run `npm run test:unit` + `npm run test:e2e`; ensure `// @ts-check` is clean across the new modules --- diff --git a/specs/005-orbat-asset-enrichment/blog/post.md b/specs/005-orbat-asset-enrichment/blog/post.md new file mode 100644 index 0000000..1904ae4 --- /dev/null +++ b/specs/005-orbat-asset-enrichment/blog/post.md @@ -0,0 +1,75 @@ +--- +layout: default +title: "The ORBAT stops being coloured dots" +--- + +> **At a glance —** ORBAT assets gain typed NATO-style symbols, an intel-confidence wash, a threat's +> see-it-vs-hit-it rings, and lightweight descriptive detail — and *every* one of them stays +> honest-floor display-only: nothing touches the route. + +![The enriched ORBAT authoring panel — kind, icon, confidence, strength, notes, and red dual-range tuners](screenshots/hero.png) + +## The problem + +[Spec 004](../004-orbat-red-green-assets/) gave us a working ORBAT: add, tune, duplicate, remove and +commit blue/red/green assets. But every asset rendered as the *same* allegiance-coloured dot with a +*single* extent ring. You couldn't tell a hospital from a SAM site, a high-confidence sighting from a +rumour, or where a threat could **see** you versus where it could **hit** you. The picture had the +right sides but none of the texture an intelligence officer actually reasons with. + +## Options + +- **Symbology: an engine vs a lookup.** A full NATO APP-6/MIL-STD-2525 symbology engine (with + affiliation frame shapes) — or a compact glyph **lookup table** keyed to a handful of platform kinds. +- **Rendering: icon atlas vs glyphs.** deck.gl's `IconLayer` with a bundled icon-atlas image — or a + `TextLayer` drawing a Unicode/emoji **glyph** over the existing marker (no asset, no new dependency, + no build step). +- **Confidence: a new scale vs reuse.** Invent a 0–1 confidence number — or reuse the project's + existing `ConfidenceLevel` (high/medium/low) vocabulary. +- **Threat reach: one extent vs two.** Keep a single radius — or split it into a **detection** ring and + an **engagement/weapon** ring on the hostile parameter group. + +## The strategy + +**Additive, schema-first, and display-only.** Every new field is authored in the LinkML schema +(Principle I) and regenerated — a `PlatformKind` enum and `kind`/`symbol`/`confidence`/`strength`/ +`notes` on `Asset`; `detection_range_m`/`engagement_range_m`/`threat_type` on `RedParams`; a +`GreenCategory` enum + `category` on `GreenParams`; `role` on `BlueParams`. The app imports the +generated types and never re-lists them. + +Symbols are **`TextLayer` glyphs** from a hand-written `SYMBOLS` lookup (the documented UI carve-out), +drawn over the allegiance-coloured marker — no icon atlas, no new runtime dependency, no build step. +Intel **confidence** becomes marker **opacity** (high→full, medium→0.6, low→0.35). Red's reach splits +into a faint outer **detection** ring and a bold inner **engagement** ring, with the model enforcing +`engagement ≤ detection`. And because spec-004 drafts already live in users' browsers, a pure, +idempotent `normalize()` **migrates** them on load — a red asset's old single `extent_m` becomes its +detection range, with an engagement ring seeded inside it. + +Crucially, **nothing crosses the honest floor (NF9)**: not one enriched attribute is read by the +kernel or any plan. An e2e test re-asserts that tuning any of them leaves the committed plan ids +byte-for-byte identical. + +## The results + +- **Typed symbols** across all three sides (infantry/vehicle/aircraft/vessel/sensor/emplacement/ + structure), overridable per asset. +- **Intel confidence** as a pre-attentive opacity wash on the map. +- **Red dual range rings** — detection vs engagement — with the engagement ring never drawn larger than + detection. +- **Descriptive detail** — strength, notes, red threat-type, green category, blue role — shown in the + roster and on the marker hover tooltip. +- **Backward compatible**: spec-004 ORBATs load intact and migrate cleanly. +- **The invariants hold, and the tests prove it**: **12 new unit tests** (30 total) and **4 new e2e + tests** (9 total) are green, alongside `0` typecheck errors. Determinism (NF3) and the honest floor + (NF9) are both asserted. + +## Screenshots + +The enriched authoring panel — kind, icon override, confidence, strength, notes; red shows detection + +engagement (the single extent is gone for red); green/blue keep their single extent: + +![The enriched ORBAT panel with all the new per-row controls](screenshots/enriched-panel.png) + +The map reads like a recognised picture — typed allegiance symbols in place of plain dots: + +![Typed ORBAT symbols on the Solway map](screenshots/map-symbols.png) diff --git a/specs/005-orbat-asset-enrichment/blog/screenshots/enriched-panel.png b/specs/005-orbat-asset-enrichment/blog/screenshots/enriched-panel.png new file mode 100644 index 0000000..4b9eb15 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/blog/screenshots/enriched-panel.png differ diff --git a/specs/005-orbat-asset-enrichment/blog/screenshots/hero.png b/specs/005-orbat-asset-enrichment/blog/screenshots/hero.png new file mode 100644 index 0000000..4b9eb15 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/blog/screenshots/hero.png differ diff --git a/specs/005-orbat-asset-enrichment/blog/screenshots/map-symbols.png b/specs/005-orbat-asset-enrichment/blog/screenshots/map-symbols.png new file mode 100644 index 0000000..a6a3348 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/blog/screenshots/map-symbols.png differ diff --git a/specs/005-orbat-asset-enrichment/checklists/requirements.md b/specs/005-orbat-asset-enrichment/checklists/requirements.md new file mode 100644 index 0000000..3b00149 --- /dev/null +++ b/specs/005-orbat-asset-enrichment/checklists/requirements.md @@ -0,0 +1,46 @@ +# Specification Quality Checklist: ORBAT asset enrichment — kind, icons, confidence & red dual-range rings + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Scope is **three independently-shippable slices**: A (kind + icons + confidence, P1), B (red dual-range + rings, P2), and C (descriptive detail — strength, notes, red threat-type / green category / blue role, + P3). Each delivers value alone; A is the recommended MVP. +- Entirely **display-only and additive** (NF9 honest floor): no new attribute touches routing or the + kernel — asserted by FR-009/SC-004. New fields are schema-defined and regenerated (Principle I, + ADR-0012/0026), reusing the existing `ConfidenceLevel` vocabulary. +- Backward compatibility with spec-004 drafts is an explicit requirement (FR-010/SC-005), including + migrating a red asset's single `extent_m` to its detection range. +- A richer place-on-map interaction and full APP-6/2525 symbology fidelity are explicitly **out of + scope** (noted in Assumptions) — candidates for a future spec. +- Symbol-set specifics (exact glyphs per kind) are an implementation/design choice left to planning; + the spec fixes the *behaviour* (kind+allegiance → distinct symbol, overridable), not the artwork. +- All checklist items pass on first iteration. Ready for `/speckit-clarify` (optional) or `/speckit-plan`. diff --git a/specs/005-orbat-asset-enrichment/contracts/orbat-enrichment.md b/specs/005-orbat-asset-enrichment/contracts/orbat-enrichment.md new file mode 100644 index 0000000..a3e1243 --- /dev/null +++ b/specs/005-orbat-asset-enrichment/contracts/orbat-enrichment.md @@ -0,0 +1,53 @@ +# Contract — ORBAT asset enrichment (additions to spec 004) + +Additive to the spec-004 contracts (`orbat-store.md`, `orbat-ui.md`). The ORBAT model +(`app/js/orbat/orbat.js`) stays the only writer; UI and rendering read through it. All operations +remain pure/deterministic and display-only (NF9): no addition here is read by the kernel or any plan. + +## Types (regenerated from the schema — do not redeclare) + +`Asset` gains `kind: PlatformKind?`, `symbol: string?`, `confidence: ConfidenceLevel?`, `strength: +string?`, `notes: string?`. `RedParams` gains `detection_range_m: number?`, `engagement_range_m: +number?`, `threat_type: string?`. `GreenParams` gains `category: GreenCategory?`. `BlueParams` gains +`role: string?`. `PlatformKind` and `GreenCategory` are new enums; `ConfidenceLevel` is the existing one. + +## Model operations (additions / changes) + +| Function | Change | Guarantees | +|---|---|---| +| `tuneAsset(orbat, id, patch)` | accepts `kind`, `symbol`, `confidence`, `strength`, `notes` on the asset; `detection_range_m`/`engagement_range_m`/`threat_type` inside `patch.red`; `category` inside `patch.green`; `role` inside `patch.blue` | clamps both ranges to `BOUNDS.extent_m`; reconciles `engagement_range_m ≤ detection_range_m` (FR-006); validates `kind`/`confidence`/`category` against their vocabularies; trims free-text + drops empties; touches only the targeted asset (SC-003). | +| `normalize(orbat)` (new, applied by `loadDraft`/`getDraft`) | defaults absent fields; **migrates** a red asset's single `extent_m` → `detection_range_m`, seeding `engagement_range_m` ≤ it | pure + idempotent; preserves canonical bytes/identity for already-migrated rosters (NF3/FR-010). | +| `addAsset(orbat, { allegiance, … })` | red seeds `detection_range_m`/`engagement_range_m` defaults; all allegiances may carry `kind`/`confidence` | unchanged id/isolation guarantees. | +| `validate(asset)` | also checks `kind ∈ PlatformKind`, `confidence ∈ ConfidenceLevel`, range bounds + `engagement ≤ detection` | `{ ok, issues[] }`; display feedback, never blocks. | +| `symbolOf(asset)` (new, UI carve-out) | returns the glyph: `asset.symbol` if set, else `SYMBOLS[kind]`, else the generic dot | pure; no kernel reference. | + +`duplicateAsset` / `removeAsset` / `commit` / `canonical` / `reconcileOwnForce` are **unchanged** — the +new fields deep-copy, persist, and canonicalise like any other asset field. + +## UI affordances (panel — additions to `orbat-ui.md`) + +| Affordance | Behaviour | Backing requirement | +|---|---|---| +| **Kind selector** | per-row select over the `PlatformKind` vocabulary; changes the map symbol live. | FR-001/002, US1 | +| **Icon/symbol picker** | choose an override glyph for one asset; a **clear** affordance reverts to the kind+allegiance default. | FR-003, US1 | +| **Confidence selector** | per-row high/medium/low; map emphasis + roster badge update live. | FR-004, US2 | +| **Red dual-range tuners** | red rows show **detection** and **engagement** range inputs (replacing the single extent); the single extent tuner remains for green/blue. | FR-005/006/007, US3 | +| **Strength + notes** | per-row strength descriptor and free-text notes; shown in the roster, omitted when empty. | FR-012/014, US4 | +| **Per-allegiance descriptor** | red **threat type** (text), green **category** (select over `GreenCategory`), blue **role** (text). | FR-013, US4 | + +## Projection contract (display-only) + +- **Map** (`map.render`): each asset draws its **symbol glyph** (`TextLayer`) over the allegiance + marker; **confidence** sets marker/glyph **opacity** (high→full, medium→~0.6, low→~0.35). **Red** + assets draw a faint outer **detection** ring and a bold inner **engagement** ring (`detection ≥ + engagement`); green/blue draw their single extent ring. Existing `data-assets` attribute is retained; + it MAY be extended with kind/confidence for e2e assertions. +- **Honest floor + determinism**: nothing the panel/map shows changes the route or plan (FR-009/NF9); + identical authoring ⇒ identical projection (NF3). + +## Non-goals (deferred) + +- No NATO APP-6/2525 symbology engine or affiliation frame shapes (a glyph lookup, not an engine). +- No icon-atlas image asset / new dependency (glyphs via `TextLayer`). +- No routing/kernel influence from any enriched attribute (the avoid-assess capability stays deferred, + DEC-51); no richer place-on-map interaction (spec-004 default placement reused). diff --git a/specs/005-orbat-asset-enrichment/data-model.md b/specs/005-orbat-asset-enrichment/data-model.md new file mode 100644 index 0000000..8c65953 --- /dev/null +++ b/specs/005-orbat-asset-enrichment/data-model.md @@ -0,0 +1,151 @@ +# Phase 1 — Data Model: ORBAT asset enrichment + +**Source of truth = LinkML** (Principle I). The shapes below are authored in the schema modules and +**regenerated** into `schema/gen/remit.schema.json` + `schema/gen/remit.ts` via `schema/generate.sh`. +The app imports the generated TS. The kind → glyph lookup is the documented behaviour/UI carve-out and +stays hand-written in `app/js`. + +## Schema additions + +### `common.yaml` — new enum + +```yaml +enums: + PlatformKind: + description: >- + The platform/type class of an ORBAT asset (DEC-60). Orthogonal to allegiance; drives the + map symbol. Display-only in v1 (NF9). v1 set, pluggable like ActivityType (NF7). + permissible_values: + infantry: { description: dismounted personnel } + vehicle: { description: ground vehicle } + aircraft: { description: fixed/rotary wing } + vessel: { description: surface/sub-surface craft } + sensor: { description: sensor / radar / observation post } + emplacement: { description: fixed weapon / SAM / fortified position } + structure: { description: building / installation / facility } +``` + +> `ConfidenceLevel` (high|medium|low) already exists in `common.yaml` — reused, not redefined. + +### `common.yaml` — green category enum + +```yaml +enums: + GreenCategory: + description: The category of a neutral/green protected place (DEC-60 J3); display-only in v1. + permissible_values: + hospital: { description: medical facility } + school: { description: educational facility } + utility: { description: power / water / comms infrastructure } + place_of_worship: { description: religious site } + residential: { description: populated residential area } + other: { description: uncategorised protected place } +``` + +### `orbat.yaml` — three attributes on `Asset` + +```yaml + attributes: + # ...existing id/allegiance/label/position/extent_m/blue/red/green/canonical_own_force... + kind: + range: PlatformKind + description: platform type (DEC-60); drives the map symbol. Absent ⇒ generic marker. + symbol: + description: optional per-asset symbol override (a glyph); absent ⇒ derived from kind + allegiance + confidence: + range: ConfidenceLevel + description: intel reliability of this asset (DEC-19); rendered as marker emphasis + a roster badge + strength: + description: free-text strength descriptor (e.g. "×3", "platoon"); display-only + notes: + description: free-text operator notes; display-only +``` + +### `orbat.yaml` — descriptive + dual-range attributes on the param groups + +```yaml + RedParams: + attributes: + severity: { ... } # unchanged + active_windows: { ... } # unchanged + detection_range_m: + range: float + description: outer reach the threat can DETECT within (metres); the faint outer ring + engagement_range_m: + range: float + description: inner reach the threat can ENGAGE within (metres); the bold inner ring (≤ detection) + threat_type: + description: free-text threat/weapon type (e.g. SAM, MG, armour); display-only + + GreenParams: + attributes: + sensitivity: { ... } # unchanged + protection: { ... } # unchanged + category: + range: GreenCategory + description: kind of protected place (hospital/school/…); display-only + + BlueParams: + attributes: + availability: { ... } # unchanged + capabilities: { ... } # unchanged + availability_window: { ... } # unchanged (spec 004 fix) + role: + description: free-text own-force role (e.g. recce, C2, fires); display-only +``` + +> `Asset.extent_m` is retained for green/blue (their single ring). For red it is superseded by the dual +> ranges and used only as the migration source for `detection_range_m` (see Lifecycle below). + +## Entities (summary of changes) + +| Entity | New fields | Notes | +|---|---|---| +| **Asset** | `kind` (PlatformKind), `symbol` (string override), `confidence` (ConfidenceLevel), `strength` (string), `notes` (string) | all optional, additive; display-only | +| **RedParams** | `detection_range_m`, `engagement_range_m` (float), `threat_type` (string) | red-only dual rings (`engagement ≤ detection`) + threat descriptor | +| **GreenParams** | `category` (GreenCategory) | protected-place kind | +| **BlueParams** | `role` (string) | own-force role descriptor | + +## Validation rules (from the spec) + +- **FR-001/002**: `kind` ∈ the `PlatformKind` vocabulary (or unset → generic symbol). The rendered + symbol is `symbol` if set, else derived from `kind` + `allegiance`, else the generic dot. +- **FR-004**: `confidence` ∈ {high, medium, low} (or unset → full-emphasis default). +- **FR-005/006**: `detection_range_m` and `engagement_range_m` are clamped to the extent bounds + (`BOUNDS.extent_m`, 100..20000 m); `engagement_range_m` is reconciled to be ≤ `detection_range_m` + (clamp + inline feedback), never silently dropped. +- **FR-007**: dual ranges apply to **red only**; green/blue validate/render the single `extent_m`. +- **FR-013**: green `category` ∈ the `GreenCategory` vocabulary (or unset); red `threat_type`, blue + `role`, and shared `strength`/`notes` are free text (trimmed; empty ⇒ omitted, FR-014). +- **FR-009/015/NF9**: none of `kind`/`symbol`/`confidence`/`strength`/`notes`/`detection_range_m`/ + `engagement_range_m`/`threat_type`/`category`/`role` is read by the kernel or any plan term — + display-only. + +## Lifecycle / migration (FR-010, backward compatibility) + +```text +load draft / committed Orbat ─► normalise(asset): + kind absent → leave unset (generic symbol) + confidence absent → leave unset (full-emphasis default) + red & no dual range: + detection_range_m ← extent_m ?? DEFAULT_EXTENT.red + engagement_range_m ← round(0.5 × detection_range_m), clamped, ≤ detection +``` + +- Normalisation is a **pure, idempotent** function of prior content, so canonical/sorted serialisation + (DEC-35) and determinism (NF3) hold: a spec-004 draft canonicalises identically before and after a + no-op normalise once migrated. +- Persistence, duplicate, remove and commit are **unchanged** from spec 004 (ADR-0026); the new fields + ride along in the same canonical JSON draft and content-addressed `Orbat` commit. + +## App rendering (display-only, hand-written carve-out) + +- `map.js`: a `SYMBOLS` glyph lookup (`kind` → Unicode/emoji) renders an asset's symbol via the existing + `TextLayer`, over the allegiance-coloured marker; `confidence` sets marker (and glyph) **opacity**. + Red assets draw **two** `ScatterplotLayer` rings — faint outer `detection_range_m`, bold inner + `engagement_range_m`; green/blue keep the single `extent_m` ring. +- `orbat-panel.js`: each row gains a **kind** selector, an **icon/symbol** picker with a clear-override + affordance, a **confidence** selector, **strength** + **notes** inputs, a per-allegiance descriptor + control (red **threat type** text, green **category** select, blue **role** text), and — for red rows — + **detection** and **engagement** range tuners (the single extent tuner stays for green/blue). Empty + descriptive values are omitted from the roster display, not shown blank. diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/01-empty-roster.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/01-empty-roster.png new file mode 100644 index 0000000..f0eec7d Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/01-empty-roster.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/02-two-red-tuned.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/02-two-red-tuned.png new file mode 100644 index 0000000..5d71cd3 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/02-two-red-tuned.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/03-green-assets.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/03-green-assets.png new file mode 100644 index 0000000..44630c4 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/03-green-assets.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/04-blue-pool-route-unchanged.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/04-blue-pool-route-unchanged.png new file mode 100644 index 0000000..65b8cdb Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/04-blue-pool-route-unchanged.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/05-roster-managed.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/05-roster-managed.png new file mode 100644 index 0000000..b8a359c Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/05-roster-managed.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/06-sync-matrix-track.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/06-sync-matrix-track.png new file mode 100644 index 0000000..6d4de2b Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/06-sync-matrix-track.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/07-kind-symbols.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/07-kind-symbols.png new file mode 100644 index 0000000..155ab86 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/07-kind-symbols.png differ diff --git a/specs/005-orbat-asset-enrichment/evidence/screenshots/08-red-dual-range.png b/specs/005-orbat-asset-enrichment/evidence/screenshots/08-red-dual-range.png new file mode 100644 index 0000000..7134766 Binary files /dev/null and b/specs/005-orbat-asset-enrichment/evidence/screenshots/08-red-dual-range.png differ diff --git a/specs/005-orbat-asset-enrichment/plan.md b/specs/005-orbat-asset-enrichment/plan.md new file mode 100644 index 0000000..d901cea --- /dev/null +++ b/specs/005-orbat-asset-enrichment/plan.md @@ -0,0 +1,139 @@ +# Implementation Plan: ORBAT asset enrichment — kind, icons, confidence & red dual-range rings + +**Branch**: `claude/spec-04-implement-0u0s9y` (spec dir `005-orbat-asset-enrichment`) | **Date**: 2026-06-13 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/005-orbat-asset-enrichment/spec.md` + +## Summary + +Make the ORBAT map read like a recognised operational picture rather than coloured dots, without +crossing the honest floor (NF9). **Display-only, additive** enrichments to the spec-004 `Asset`: +a shared **platform `kind`** vocabulary that drives an allegiance-framed **map symbol** (with a +per-asset override), an intel **`confidence`** level (reusing the existing `ConfidenceLevel`) reflected +as marker emphasis + a roster badge, for red (hostile) assets **dual range rings** +(`detection_range_m` / `engagement_range_m`) replacing the single extent, and lightweight **descriptive +detail** (`strength`, `notes`, and a per-allegiance descriptor — red threat type, green category, blue +role). The serialisable shape is +added to the **LinkML schema** (Principle I) and regenerated; the *glyph lookup* (kind → symbol) is the +documented UI/behaviour carve-out and stays hand-written. Map rendering reuses the existing deck.gl +layers (a `TextLayer` glyph over the allegiance-coloured marker; a second `ScatterplotLayer` ring for +red). Nothing touches the kernel/routing; spec-004 drafts load unchanged via defaulting + migration. + +## Technical Context + +**Language/Version**: JavaScript (ES modules, `// @ts-check` + JSDoc); Node ≥ 20 for tooling. + +**Primary Dependencies**: existing app stack — `maplibre-gl`, `@deck.gl/*` (`ScatterplotLayer`, +`TextLayer` already in `map.js`), `h3-js`, Vite (ADR-0014); LinkML toolchain (`schema/generate.sh`). +**No new runtime dependencies** (ADR-0014) — symbols are Unicode/emoji glyphs via the existing +`TextLayer`, not an icon atlas image. + +**Storage**: in-browser. The enriched asset persists in the same localStorage ORBAT draft and +content-addressed `Orbat` commit established in spec 004 (DEC-35/ADR-0026). + +**Testing**: `npm run test:unit` (`node --test`, `test/orbat.test.mjs`) for the model additions +(kind/confidence round-trip, dual-range clamp + `engagement ≤ detection`, spec-004 draft migration); +`npm run test:e2e` (`e2e/orbat.spec.ts`) for the authoring + projection surface; evidence screenshots +under `specs/005-orbat-asset-enrichment/evidence/`. + +**Target Platform**: modern browser (static app served from `app/`); cloud + local dev. + +**Project Type**: single static web app (no backend) — Option 1 layout. + +**Performance Goals**: authoring feedback within one frame (SC-002); legible symbol picture at the +spec-004 scale (≥ 10 instances per allegiance). + +**Constraints**: honest floor (NF9) — no attribute influences routing/the kernel; determinism (NF3) — +identical authoring ⇒ identical projection; additive + backward-compatible (FR-010) — spec-004 drafts +load with defaults, red migrates `extent_m` → `detection_range_m`; no new build machinery / runtime dep. + +**Scale/Scope**: one ORBAT per scenario; seven platform kinds; three confidence levels; two extra red +radii; lightweight descriptive fields (strength, notes, red threat-type, green category, blue role). +Builds directly on spec 004; reuses its model/panel/projection. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|---|---|---| +| **I. LinkML is the data-model source of truth** (NON-NEGOTIABLE) | ✅ PASS (gated) | New persisted fields (`kind`, `symbol`, `confidence`, red `detection_range_m`/`engagement_range_m`) are added to the schema modules and regenerated. The **glyph lookup** (kind → emoji/Unicode) is a display/UI-only single-place table — the documented carve-out — and stays hand-written. | +| **II. No-build static app** | ✅ PASS | Stays in `app/js` ES modules + `// @ts-check`; symbols via the existing `TextLayer` (no icon-atlas asset, no new dep). (Vite already adopted under ADR-0014.) | +| **III. Spec-driven workflow + blog** | ✅ PASS | spec → plan → tasks → implement; blog post sketched below (Phase 2) and authored at implement. | +| **IV. Durable project memory** | ✅ PASS | New ADR (enrichment: kind/symbol/confidence + red dual-range, display-only) and `key_facts`/`issues` entries recorded at implement. | +| **V. Repo canonical + immutability** | ✅ PASS | Reuses spec-004's immutable committed `Orbat` with lineage; no change to the commit model. | + +**No violations** → Complexity Tracking left empty. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-orbat-asset-enrichment/ +├── plan.md # This file +├── spec.md # Feature spec +├── research.md # Phase 0 — decisions resolved +├── data-model.md # Phase 1 — schema additions (kind/confidence + red dual-range) +├── contracts/ +│ └── orbat-enrichment.md # Model + UI + projection contract additions +├── quickstart.md # Phase 1 — runnable validation guide +├── checklists/ +│ └── requirements.md # Spec quality checklist (done at /speckit-specify) +├── blog/ # Authored at /speckit-implement +└── evidence/ # Playwright screenshots captured at implement +``` + +### Source Code (repository root) + +```text +schema/ +├── common.yaml # + PlatformKind enum + GreenCategory enum +├── orbat.yaml # + Asset.kind/symbol/confidence/strength/notes; RedParams.detection_range_m/engagement_range_m/threat_type; +│ # GreenParams.category; BlueParams.role +└── gen/ # REGENERATED (remit.schema.json, remit.ts) — do not hand-edit + +app/js/ +├── orbat/orbat.js # tuneAsset: kind/symbol/confidence/strength/notes + per-allegiance descriptor + red dual-range clamp +│ # (engagement ≤ detection); defaults + spec-004 migration (extent_m → detection_range_m); SYMBOLS (UI carve-out) +├── shell/orbat-panel.js # + kind/icon/confidence + strength/notes + threat-type/category/role + red detection/engagement tuners +└── views/map.js # symbol glyph (TextLayer) over allegiance marker; confidence → opacity; red two-ring rendering + +test/orbat.test.mjs # + kind/confidence round-trip, dual-range clamp/invariant, draft migration +e2e/orbat.spec.ts # + typed-symbol + confidence emphasis + red dual-ring + route-unchanged +``` + +**Structure Decision**: Single static web app (Option 1). Extend the spec-004 surfaces in place — +schema (`common.yaml` enum + `orbat.yaml` fields, regenerated), the model module, the panel, and the +map renderer — rather than adding new modules. No new files beyond docs/tests. + +## Complexity Tracking + +> No constitution violations — section intentionally empty. + +## Phase 2 — Blog post plan (REMIT) + +Planning only (authored at `/speckit-implement` into `specs/005-orbat-asset-enrichment/blog/post.md`, +from `docs/blog-post-template.md`): + +- **At a glance**: **"The ORBAT stops being coloured dots — typed symbols, an intel-confidence wash, + and a threat's see-it vs hit-it rings, all still honest-floor display-only."** Featured screenshot: + the map with kind symbols across the three sides and a red asset's two concentric range rings. +- **The problem**: spec 004 rendered every asset as a same-looking coloured dot with one extent ring — + no platform type, no intel reliability, and a single radius that conflated a threat's detection and + weapon reach. +- **Options**: (a) NATO APP-6/2525 symbology engine vs (b) a compact glyph lookup table; (c) deck.gl + `IconLayer` with an icon atlas vs (d) a `TextLayer` Unicode/emoji glyph (no asset, no dep); (e) a new + confidence scale vs reusing `ConfidenceLevel`; (f) red ranges as two `Asset.extent`s vs a `RedParams` + detection/engagement pair. +- **The strategy**: schema-define `kind`/`symbol`/`confidence` + red `detection_range_m`/ + `engagement_range_m` and regenerate (Principle I); render symbols as `TextLayer` glyphs over the + allegiance marker (no-build, no atlas); confidence → marker opacity + roster badge; enforce + `engagement ≤ detection`; keep everything display-only (NF9) and backward-compatible (migrate the + old single `extent_m`). +- **The results**: typed allegiance-framed symbols; a confidence wash; red dual rings; spec-004 drafts + still load; route/plan unchanged by any tune; determinism preserved. +- **Screenshots to capture** (Playwright → `evidence/screenshots/*.png`): dots → typed symbols (before/ + after); the three allegiances with distinct kind glyphs; a low-confidence threat faded next to a + high-confidence one; a red asset's detection + engagement rings; the enriched roster row (kind/icon/ + confidence/range tuners); a spec-004 draft loading intact after the upgrade. diff --git a/specs/005-orbat-asset-enrichment/quickstart.md b/specs/005-orbat-asset-enrichment/quickstart.md new file mode 100644 index 0000000..b18d4af --- /dev/null +++ b/specs/005-orbat-asset-enrichment/quickstart.md @@ -0,0 +1,76 @@ +# Quickstart — validate ORBAT asset enrichment + +A runnable validation guide proving the enrichment end-to-end. Details live in +[data-model.md](./data-model.md) and [contracts/](./contracts/); this is the run/verify path. Builds on +spec 004 — the ORBAT tab, placement, persistence and commit are unchanged. + +## Prerequisites + +```bash +npm install +# Data-model changes require regenerating the schema artefacts (bootstraps a LinkML venv): +bash schema/generate.sh # → schema/gen/remit.schema.json, schema/gen/remit.ts +``` + +## Run the app + +```bash +npm run dev # Vite dev server +# Overview tab → "Load the operating area"; then open the ORBAT (SME Int) tab +``` + +## Validation scenarios (map to user stories) + +### US1 — Type an asset & see the right symbol (P1) +1. Add one asset of each allegiance; set a different **kind** on each (e.g. red emplacement, green + structure, blue vehicle) → each map marker shows the corresponding **kind+allegiance symbol**, not a + plain dot; the roster row shows the kind. +2. **Override** one asset's icon → only that marker's glyph changes; **clear** it → reverts to the + kind+allegiance default. +3. Confirm a selected COA's route/plan is unchanged by any kind/icon change (honest floor). + +### US2 — Intel confidence (P1) +1. Set a red asset's **confidence** to "low" → its marker renders faded and the roster shows a low badge. +2. Raise it to "high" → emphasis updates immediately; reload → the value persists. + +### US3 — Red dual range rings (P2) +1. On a red asset, set **detection** and **engagement** ranges to different values → two concentric + rings render (detection outer/faint, engagement inner/bold). +2. Try to set **engagement > detection** → it is reconciled to ≤ detection with inline feedback (never + an invalid state). +3. Confirm a **green/blue** asset still shows a single extent control and single ring. + +### US4 — Descriptive detail (P3) +1. On a red asset set **strength** "×2", **notes** "dug in", and **threat type** "SAM" → all three show + in the roster and persist across reload. +2. Set a green asset's **category** to "hospital" and a blue asset's **role** to "recce" → each shows in + its roster row; clearing a field removes it (no blank line). +3. Confirm a selected COA's route/plan is unchanged by any descriptive edit (honest floor). + +### Backward compatibility (FR-010) +1. With a spec-004 ORBAT draft already in localStorage (assets without kind/confidence/dual-range), + load the app → every prior asset still renders (generic symbol, full emphasis) and a red asset shows + detection = its old extent with an engagement ring inside it. + +## Automated checks + +```bash +npm run test:unit # kind/confidence round-trip, dual-range clamp + engagement ≤ detection, draft migration +npm run test:e2e # cloud Playwright: typed symbol + confidence emphasis + red dual-ring + route-unchanged +``` + +Capture evidence screenshots into `specs/005-orbat-asset-enrichment/evidence/screenshots/` during the +e2e run (dots → typed symbols, three allegiances, low vs high confidence, red two rings, the enriched +roster row, a spec-004 draft loading intact). + +## Expected outcomes (success criteria) + +- Each asset's **kind and side are legible at a glance** from its symbol; no two of the seven kinds look + identical within an allegiance (SC-001). +- Kind/icon/confidence/range edits update map + roster **within one frame** and persist at 100% on + reload (SC-002). +- A red threat's **detection and engagement reaches are both visible and distinct**, engagement never + drawn larger than detection (SC-003). +- Tuning **any** enriched attribute leaves the selected route/plan **unchanged**; re-projecting an + unchanged roster is identical (SC-004). +- A spec-004 ORBAT **loads without error** with every prior asset visible and editable (SC-005). diff --git a/specs/005-orbat-asset-enrichment/research.md b/specs/005-orbat-asset-enrichment/research.md new file mode 100644 index 0000000..132636e --- /dev/null +++ b/specs/005-orbat-asset-enrichment/research.md @@ -0,0 +1,82 @@ +# Phase 0 — Research & Decisions: ORBAT asset enrichment + +All decisions resolve the spec's choices against the spec-004 codebase, the register (DEC-52/60, NF3/ +NF9) and the constitution (Principle I, no-build). No open `NEEDS CLARIFICATION` remain. + +## D1 — Symbols as `TextLayer` glyphs, not an icon atlas + +- **Decision**: render each asset's symbol as a **Unicode/emoji glyph** via the existing deck.gl + `TextLayer` (already imported in `map.js`), drawn over the allegiance-coloured `ScatterplotLayer` + marker that supplies the affiliation framing/colour. A hand-written `SYMBOLS` lookup maps each + `kind` → glyph; an unset kind falls back to today's dot. A per-asset `symbol` override replaces the + glyph string. +- **Rationale**: no icon-atlas image, no new dependency, no build step (Principle II) — and it is + deterministic. The glyph lookup is display/UI-only behaviour (the documented ADR-0012 carve-out), so + it stays in `app/js`; only the *data* (`kind`, `symbol`, `confidence`) is schema-defined. +- **Glyph table (v1, illustrative — tunable at implement)**: infantry 👤 · vehicle 🚙 · aircraft ✈ · + vessel 🚢 · sensor 📡 · emplacement 🛡 · structure 🏢 · (unset) ● . +- **Alternatives considered**: deck.gl `IconLayer` with an SVG/PNG atlas (rejected — needs a bundled + image asset + atlas plumbing, heavier than v1 scaffolding warrants); a full APP-6/MIL-STD-2525 + symbology engine (rejected — out of scope per the spec; a lookup table, not an engine). Affiliation + *frame shapes* (hostile diamond / neutral square / friendly rectangle) deferred — colour framing via + the existing circular marker is enough for v1; PolygonLayer frames are a later polish. + +## D2 — `kind` is a schema enum; the glyph map is UI + +- **Decision**: add a `PlatformKind` enum to `schema/common.yaml` + (`infantry|vehicle|aircraft|vessel|sensor|emplacement|structure`) and an optional `Asset.kind` + (range `PlatformKind`) + optional `Asset.symbol` (string override) to `schema/orbat.yaml`; regenerate. +- **Rationale**: `kind` is persisted, serialisable object-core → Principle I owns it. The kind→glyph + rendering is behaviour/UI (carve-out). Keeping the vocabulary in the schema means the HTML reference + and JSON Schema document the allowed kinds. +- **Alternatives considered**: a free-text `kind` string (rejected — loses the controlled vocabulary + the symbol map keys off, and validation); per-allegiance kind enums (rejected — kind is orthogonal to + side; an emplacement can be red or blue). + +## D3 — Confidence reuses `ConfidenceLevel`; rendered as emphasis + +- **Decision**: add optional `Asset.confidence` (range the existing `ConfidenceLevel` = + high|medium|low). The map reflects it as **marker opacity** (high ≈ 1.0, medium ≈ 0.6, low ≈ 0.35) + and the roster shows a small confidence badge. Absent ⇒ rendered at full emphasis (treated as the + unflagged default). +- **Rationale**: confidence is the defining attribute of an *Intel* picture and the vocabulary already + exists (no new scale, no schema churn beyond the attribute). Opacity is an honest, pre-attentive cue + that needs no extra layer. +- **Alternatives considered**: a numeric 0–1 confidence (rejected — `ConfidenceLevel` is the + project-standard banding, NF10-style); a separate legend/encoding (deferred — opacity + badge suffice + for v1). + +## D4 — Red dual ranges live on `RedParams`; `engagement ≤ detection` enforced + +- **Decision**: add `detection_range_m` and `engagement_range_m` (both `float`) to `RedParams`. The map + draws a **faint outer detection ring** and a **bolder inner engagement ring**. `tuneAsset` clamps both + to the extent bounds and reconciles `engagement_range_m ≤ detection_range_m` (with inline feedback), + never silently dropping a value (FR-006). Green/blue keep the single `Asset.extent_m` + single ring. +- **Rationale**: a threat's "where it can see me" and "where it can hit me" are operationally distinct; + putting both on `RedParams` keeps them red-only (FR-007) and leaves green/blue untouched. Reuses the + existing `ScatterplotLayer` ring (two instances) — no new layer type. +- **Alternatives considered**: two `Asset.extent` fields shared by all allegiances (rejected — green/ + blue don't have a detection/engagement distinction); a single extent + a ratio (rejected — less + legible/tunable than two explicit radii). + +## D5 — Backward compatibility: default + migrate spec-004 drafts + +- **Decision**: loading is tolerant (FR-010). Assets without `kind`/`symbol` render the generic dot; + without `confidence` render at full emphasis. A **red** asset without dual ranges **migrates** its + prior single `extent_m` to `detection_range_m`, with `engagement_range_m` defaulting to a fraction + (e.g. 0.5 × detection) within it. Migration happens on load/normalisation in the model, idempotently. +- **Rationale**: spec-004 drafts already live in users' localStorage and committed `Orbat`s; they must + keep loading and rendering (SC-005). Canonical/sorted serialisation (DEC-35/NF3) is preserved because + migration is a pure function of the prior content. +- **Alternatives considered**: a versioned migration framework (rejected — overkill for one additive + step; a normalising loader is enough); refusing old drafts (rejected — breaks persistence guarantees). + +## D6 — Honest floor (NF9) & determinism (NF3) unchanged + +- **Decision**: no new attribute is read by the kernel, `buildEntities` plan terms, or any routing + path. The enriched fields feed only the marker glyph/opacity/rings and the roster. Identical authoring + ⇒ identical canonical bytes ⇒ identical projection. +- **Rationale**: this is display-only scaffolding (DEC-56/ADR-0026). Threat-reactive routing remains the + deferred avoid-assess capability (DEC-51). An e2e re-asserts "tune ⇒ committed Plan ids unchanged". +- **Alternatives considered**: letting `engagement_range_m` nudge `edgeCost` for a plan-around demo + (rejected — breaches NF9 in v1; that is the deferred capability). diff --git a/specs/005-orbat-asset-enrichment/spec.md b/specs/005-orbat-asset-enrichment/spec.md new file mode 100644 index 0000000..2b26b7a --- /dev/null +++ b/specs/005-orbat-asset-enrichment/spec.md @@ -0,0 +1,236 @@ +# Feature Specification: ORBAT asset enrichment — kind, icons, confidence & red dual-range rings + +**Feature Branch**: `claude/spec-04-implement-0u0s9y` (spec dir `005-orbat-asset-enrichment`) + +**Created**: 2026-06-13 + +**Status**: Draft + +**Input**: User description: "Enrich the SME-Int ORBAT authoring tab with richer, display-only asset characteristics (additive to the existing Asset model from spec 004; honest floor NF9 — no asset influences routing/the kernel). Slice A: a shared platform-type 'kind' controlled vocabulary on every asset, NATO-style map icons keyed by kind + allegiance with an optional per-asset icon override, and an intel 'confidence' attribute surfaced on the map and the roster. Slice B: red (hostile) assets gain dual range rings — a detection range and an engagement/weapon range — replacing the single extent with two independently-tunable radii, each drawn as a distinct ring on the map." + +## Overview + +Spec 004 delivered the ORBAT authoring scaffolding: a planner can add, tune, duplicate, remove and +commit blue/red/green assets, each an allegiance-coloured dot on the map with a single extent ring. +This feature makes the picture **read like a recognised operational picture** rather than coloured +dots — without crossing the honest floor. It is **purely additive and display-only** (NF9): every +new attribute is authored intel that the operator sees; nothing influences routing or the kernel. + +Three slices, each independently shippable: + +- **Slice A — platform kind, icons, confidence** (the recognised-picture upgrade): a shared `kind` + vocabulary, allegiance-framed symbols on the map, and intel confidence shown on every asset. +- **Slice B — red dual range rings**: a hostile asset's reach is split into a *detection* radius and + an *engagement/weapon* radius, each tunable and drawn as its own ring. +- **Slice C — descriptive detail**: lightweight strength, notes, and a per-allegiance descriptor (red + threat type, green category, blue role) that turn a typed symbol into an identifiable unit. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Type an asset and see the right symbol (Priority: P1) + +A planner adds an asset and chooses its **platform kind** (e.g. vehicle, aircraft, vessel, sensor, +emplacement, infantry, structure). The map marker becomes a **kind-and-allegiance symbol** — a +recognisable glyph framed by its side's colour — instead of a plain dot, so the operator reads the +picture at a glance. The planner can override the auto-chosen symbol for a specific asset. + +**Why this priority**: turning dots into typed symbols is the single biggest legibility gain and the +foundation the other attributes hang off; it is entirely honest-floor-safe and additive. + +**Independent Test**: add one asset of each allegiance, set a different `kind` on each, confirm each +map marker shows the corresponding kind+allegiance symbol; override one asset's icon and confirm only +that marker changes; confirm the route/plan is unchanged by any of it. + +**Acceptance Scenarios**: + +1. **Given** a new red asset, **When** its kind is set to "emplacement", **Then** its map marker shows + the emplacement symbol framed in the red (hostile) affiliation and its roster row shows the kind. +2. **Given** two assets of the same kind and allegiance, **When** one's icon is overridden, **Then** + only that asset's marker changes; the other keeps the auto-derived symbol. +3. **Given** any kind/icon change, **When** a course of action is already selected, **Then** the + selected route and plan are byte-for-byte unchanged (display-only, NF9). + +--- + +### User Story 2 — Record intel confidence on each asset (Priority: P1) + +A planner records how reliable each asset's intel is — **high / medium / low** — and the map and +roster reflect it (e.g. a low-confidence threat renders faded / flagged), so the operator can weigh +the picture by trust. + +**Why this priority**: confidence is the defining attribute of an *Intel* picture and reuses the +existing `ConfidenceLevel` vocabulary; it is cheap and high-signal. + +**Independent Test**: add a red asset, set confidence to "low", confirm its marker and roster row +visibly indicate low confidence; raise it to "high" and confirm the indication updates; confirm no +effect on routing. + +**Acceptance Scenarios**: + +1. **Given** an asset with confidence "low", **When** it is rendered, **Then** the map marker is + visibly de-emphasised (e.g. reduced opacity) and the roster shows a confidence indicator. +2. **Given** confidence is changed, **When** the change is made, **Then** the map and roster update + immediately and the value persists across reload. + +--- + +### User Story 3 — Tune a red threat's detection vs engagement reach (Priority: P2) + +A planner gives a red (hostile) asset **two** range rings — an outer **detection** range and an inner +**engagement/weapon** range — and tunes each independently. The map draws both rings distinctly +(e.g. a faint outer detection ring and a bolder inner engagement ring), so the operator sees both +"where it can see me" and "where it can hit me". + +**Why this priority**: a single extent conflates two operationally distinct reaches for a threat; +splitting them sharpens the red picture. It is the most threat-specific of the three slices, so P2. + +**Independent Test**: add a red asset, set detection and engagement ranges to different values, confirm +two distinct rings render with the inner ≤ outer relationship enforced; tune one and confirm only that +ring changes; confirm green/blue assets are unaffected and keep their single extent. + +**Acceptance Scenarios**: + +1. **Given** a red asset, **When** detection range is set larger than engagement range, **Then** the + map shows two concentric rings (detection outer, engagement inner). +2. **Given** a red asset, **When** the engagement range is set larger than the detection range, + **Then** the values are reconciled so engagement ≤ detection, with feedback (never an invalid state). +3. **Given** a green or blue asset, **When** the roster is shown, **Then** it still presents a single + extent control (dual rings are red-only). + +--- + +### User Story 4 — Describe an asset (strength, notes, type/category/role) (Priority: P3) + +A planner records lightweight **descriptive** detail on an asset: a **strength** (e.g. "×3", "platoon"), +free-text **notes**, and a per-allegiance descriptor — a red **threat type** (e.g. SAM, MG, armour), a +green **category** (hospital, school, utility, place-of-worship, residential), and a blue **role** (e.g. +recce, C2, fires). These show in the roster (and on the marker tooltip/label where natural), turning a +typed symbol into an identifiable unit. + +**Why this priority**: these are the highest-value-per-effort *descriptive* additions — trivial, +purely-additive fields with obvious roster controls — but they refine rather than transform the picture, +so they land after the visual slices (US1–US3). + +**Independent Test**: set a strength, a note, and the allegiance-specific descriptor on one asset of each +side; confirm each appears in the roster and round-trips through reload, duplicate and commit; confirm no +routing effect. + +**Acceptance Scenarios**: + +1. **Given** a red asset, **When** its threat type is set to "SAM" and strength to "×2", **Then** the + roster row shows both and they persist across reload. +2. **Given** a green asset, **When** its category is set to "hospital", **Then** the roster shows the + category (and it is available to the map tooltip/label). +3. **Given** any descriptive edit, **When** a course of action is selected, **Then** the route/plan is + unchanged (display-only, NF9). + +--- + +### Edge Cases + +- **Unknown / unset kind**: an asset with no kind chosen falls back to a generic allegiance-framed + symbol (today's dot behaviour), never an empty/broken marker. +- **Icon override cleared**: removing an override reverts the marker to the auto-derived kind+allegiance + symbol. +- **Out-of-bounds ranges**: detection/engagement radii are clamped to their declared bounds; the + engagement radius is reconciled to be ≤ the detection radius (with feedback), never silently lost. +- **Spec-004 drafts**: an ORBAT draft authored before this feature (no kind/confidence/dual-range + fields) loads cleanly — assets render with generic symbols, default confidence, and red assets adopt + detection = the prior single extent with engagement defaulting within it. +- **Confidence absent**: an asset with no confidence set renders at full emphasis (treated as the + highest/unflagged default) rather than appearing erroneously faded. +- **Empty descriptive fields**: an asset with no strength / notes / type / category / role is valid; + the roster simply omits the empty descriptor (never a blank "undefined" line). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Every asset MUST carry an optional **platform kind** drawn from a shared controlled + vocabulary (at minimum: infantry, vehicle, aircraft, vessel, sensor, emplacement, structure); + the vocabulary is the source of the asset's symbol. +- **FR-002**: The system MUST render each placed asset as a **symbol determined by its kind and + allegiance** (affiliation framing per side), replacing the plain dot, and MUST fall back to a + generic allegiance-framed symbol when kind is unset. +- **FR-003**: A planner MUST be able to **override** an individual asset's symbol; clearing the + override MUST revert to the auto-derived kind+allegiance symbol. +- **FR-004**: Every asset MUST carry an optional **intel confidence** (high / medium / low) reusing + the existing confidence vocabulary; the map MUST visibly reflect it (e.g. opacity) and the roster + MUST show a confidence indicator. +- **FR-005**: Red (hostile) assets MUST support **two independently-tunable range radii** — a + detection range and an engagement/weapon range — drawn as two visually-distinct rings on the map. +- **FR-006**: The system MUST keep the **engagement radius ≤ detection radius** invariant, reconciling + (clamping) with inline feedback when an edit would violate it; values are never silently dropped. +- **FR-007**: Green and blue assets MUST retain their single extent control and single ring; dual-range + rings are red-only. +- **FR-008**: All new attributes (kind, symbol override, confidence, detection/engagement ranges) MUST + be part of the persisted, serialisable asset shape — defined once in the data-model source of truth + and regenerated — and MUST round-trip through draft persistence, duplicate, and commit. +- **FR-009**: No new attribute MAY influence routing, the kernel, or any plan; tuning any of them MUST + leave the selected route/plan unchanged (honest floor, NF9), and identical authoring MUST produce + identical projections (determinism, NF3). +- **FR-010**: The feature MUST be **backward-compatible** with spec-004 drafts: a roster authored + without these fields MUST load without error, with sensible defaults applied (generic symbol, full + emphasis, red detection range seeded from the prior single extent). +- **FR-011**: The authoring panel MUST offer, per asset, a **kind selector**, an **icon picker** (with + a clear-override affordance), a **confidence selector**, and — for red assets — **detection and + engagement range tuners**. +- **FR-012**: Every asset MUST carry an optional **strength** descriptor and free-text **notes**, shown + in the roster (and available to the marker tooltip/label) and editable per asset. +- **FR-013**: Each allegiance MUST carry an optional descriptive type: red a **threat type**, green a + **category** (from a controlled vocabulary — e.g. hospital / school / utility / place-of-worship / + residential), and blue a **role**. +- **FR-014**: All descriptive fields (strength, notes, threat type, category, role) MUST be part of the + persisted, serialisable asset shape (schema-defined + regenerated) and MUST round-trip through draft + persistence, duplicate and commit; absent values MUST be omitted from the roster, not shown blank. +- **FR-015**: No descriptive field MAY influence routing or any plan (honest floor, NF9). + +### Key Entities *(include if feature involves data)* + +- **Asset (extended)**: gains `kind` (platform vocabulary), an optional symbol/icon override, + `confidence` (reusing the existing confidence levels), a **strength** descriptor and free-text + **notes**, and — for the hostile parameter group — a `detection_range_m` and an `engagement_range_m` + (the dual rings), alongside the spec-004 fields. +- **Platform kind vocabulary**: the controlled set of platform types that maps to symbols. +- **Symbol**: the rendered marker for an asset, derived from kind + allegiance, overridable per asset. +- **Allegiance descriptors**: red **threat type**, green **category** (controlled vocabulary), blue + **role** — lightweight per-allegiance descriptive detail on the respective parameter group. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An operator can distinguish each asset's **kind and side at a glance** from its map + symbol, with no two of the seven kinds rendering identically within an allegiance. +- **SC-002**: Setting or changing an asset's kind, icon, confidence, or a red range updates the map and + roster **within one frame** and persists across reload at 100% fidelity. +- **SC-003**: A red threat's **detection and engagement reaches are both visible and distinct** on the + map, and the engagement ring is never drawn larger than the detection ring. +- **SC-004**: Tuning **any** enriched attribute leaves the selected route/plan **unchanged** (zero + routing effect), and re-projecting an unchanged roster is **identical** (NF3). +- **SC-005**: An ORBAT authored under spec 004 **loads without error** after this feature ships, with + every prior asset still visible and editable. +- **SC-006**: An operator can read an asset's **strength, notes and type/category/role** from the roster, + and every descriptive value persists across reload, duplicate and commit at 100% fidelity. + +## Assumptions + +- **Display-only, additive.** This builds directly on spec 004's Asset/Orbat model and its map + + Sync-Matrix projection; it adds attributes and richer rendering only — no routing, allocation, or + threat-reasoning behaviour (those remain deferred capabilities, NF9 / DEC-51 / DEC-60). +- **Source of truth.** The new fields are added to the LinkML schema (the `orbat` module / shared + vocabulary) and regenerated; the app imports the generated types — consistent with ADR-0012/0026. +- **Confidence reuses the existing `ConfidenceLevel` vocabulary** (high/medium/low) rather than a new + scale. +- **Symbol set.** A compact, recognisable symbol set keyed to the seven kinds with per-allegiance + affiliation framing is sufficient for v1; full APP-6/MIL-STD-2525 fidelity is out of scope (a lookup + table of glyphs, not a symbology engine). +- **Placement, persistence, duplicate/remove and commit are unchanged** from spec 004 and are reused. +- **Backward compatibility.** Spec-004 drafts in localStorage must keep loading; defaults fill the new + fields. Red assets migrate their single `extent_m` to the detection range. +- **Descriptive fields are lightweight and display-only.** `strength`, `notes`, red `threat_type` and + blue `role` are free-text; green `category` is a small controlled vocabulary. They are shown in the + roster (and available to the marker tooltip/label) and never influence routing. +- **Map placement remains the spec-004 default-position model** (the panel and map live in different + role-tabs); a richer **place-on-map** interaction and true **NATO affiliation frame shapes** + (diamond/square/rectangle) are explicitly out of scope for this feature (candidates for a later slice). diff --git a/specs/005-orbat-asset-enrichment/tasks.md b/specs/005-orbat-asset-enrichment/tasks.md new file mode 100644 index 0000000..2e3f34f --- /dev/null +++ b/specs/005-orbat-asset-enrichment/tasks.md @@ -0,0 +1,190 @@ +--- +description: "Task list for ORBAT asset enrichment — kind, icons, confidence & red dual-range rings" +--- + +# Tasks: ORBAT asset enrichment — kind, icons, confidence & red dual-range rings + +**Input**: Design documents from `specs/005-orbat-asset-enrichment/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: INCLUDED — the project's quality gates require `npm run test:unit` (`node --test`, `test/*.test.mjs`) and `npm run test:e2e` (Playwright cloud wrapper, `e2e/*.spec.ts`) on every PR (constitution §Development Workflow). Graphical work captures evidence screenshots. + +**Organization**: grouped by user story (US1–US3 from spec.md). Additive to spec 004 — extends the existing `orbat.js` model, `orbat-panel.js` surface, and `map.js` renderer in place; no new app modules. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: can run in parallel (different files, no dependency on incomplete tasks) +- **[Story]**: US1 / US2 / US3 (setup, foundational, polish carry no story label) + +## Path Conventions + +Single static web app: schema in `schema/`, app in `app/js/`, unit tests in `test/*.test.mjs`, e2e in `e2e/*.spec.ts`. + +--- + +## Phase 1: Setup (Schema — source of truth, Principle I) + +**Purpose**: Extend the LinkML data model with the enriched, serialisable fields and regenerate. Generated artefacts are outputs; never hand-edited. + +- [X] T001 Add `PlatformKind` enum (`infantry|vehicle|aircraft|vessel|sensor|emplacement|structure`) and `GreenCategory` enum (`hospital|school|utility|place_of_worship|residential|other`) to `schema/common.yaml` +- [X] T002 Add optional `kind` (range `PlatformKind`), `symbol` (string override), `confidence` (range `ConfidenceLevel`), `strength` (string), and `notes` (string) attributes to `Asset` in `schema/orbat.yaml` +- [X] T003 Add `detection_range_m`/`engagement_range_m` (float) + `threat_type` (string) to `RedParams`, `category` (range `GreenCategory`) to `GreenParams`, and `role` (string) to `BlueParams` in `schema/orbat.yaml` +- [X] T004 Regenerate artefacts: `bash schema/generate.sh` → verify `schema/gen/remit.ts` + `remit.schema.json` include `PlatformKind`/`GreenCategory`, `Asset.kind/symbol/confidence/strength/notes`, `RedParams.detection_range_m/engagement_range_m/threat_type`, `GreenParams.category`, `BlueParams.role`; do NOT hand-edit generated files + +**Checkpoint**: schema regenerated; enriched asset shapes available as generated types. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared model behaviour every story builds on — backward-compatible loading, the tune/validate/default extensions, and the symbol lookup. Not a demoable story on its own. + +**⚠️ CRITICAL**: No user-story work begins until this phase is complete. + +- [X] T005 Implement `normalize(orbat)` — default absent fields and **migrate** spec-004 drafts (red `extent_m` → `detection_range_m`, seed `engagement_range_m ≈ 0.5×` clamped ≤ detection); pure + idempotent; wire it into `loadDraft`/`getDraft` (FR-010) — in `app/js/orbat/orbat.js` +- [X] T006 Extend `tuneAsset` to accept `kind`/`symbol`/`confidence`/`strength`/`notes` on the asset, `detection_range_m`/`engagement_range_m`/`threat_type` inside `patch.red` (clamp to `BOUNDS.extent_m`; reconcile `engagement ≤ detection`, FR-006), `category` inside `patch.green`, and `role` inside `patch.blue`; trim free-text + drop empties; extend `addAsset` red defaults and `validate` (kind/confidence/category vocab + range bounds) — in `app/js/orbat/orbat.js` +- [X] T007 Add the `SYMBOLS` glyph lookup (`PlatformKind` → Unicode/emoji) + `symbolOf(asset)` (override → kind → generic dot) export (UI/behaviour carve-out, ADR-0012) — in `app/js/orbat/orbat.js` + +**Checkpoint**: a spec-004 draft loads + migrates cleanly; enriched fields tune/clamp; `symbolOf` resolves. Stories can begin. + +--- + +## Phase 3: User Story 1 — Type an asset & see the right symbol (Priority: P1) 🎯 MVP + +**Goal**: A planner sets an asset's platform `kind`; the map marker becomes a kind+allegiance symbol (overridable per asset) instead of a plain dot. + +**Independent Test**: add one asset per allegiance with different kinds → each shows the matching symbol; override one icon then clear it → only that marker changes and reverts; the selected route/plan is unchanged. + +### Tests for User Story 1 + +- [X] T008 [P] [US1] Unit tests — `symbolOf` (override > kind > generic), `kind`/`symbol` round-trip through `tuneAsset`/`canonical`, determinism — in `test/orbat.test.mjs` +- [X] T009 [P] [US1] e2e — set distinct kinds across the three allegiances, assert distinct symbols; override + clear one icon; assert route/plan unchanged (honest floor) — in `e2e/orbat.spec.ts` + +### Implementation for User Story 1 + +- [X] T010 [US1] Render the per-row **kind selector** and **icon/symbol picker** (with a clear-override affordance) in `app/js/shell/orbat-panel.js` +- [X] T011 [US1] Draw each asset's symbol glyph (`TextLayer` via `symbolOf`) over the allegiance marker; extend the `data-assets` dataset with kind for the e2e suite; capture evidence screenshots → `specs/005-orbat-asset-enrichment/evidence/screenshots/` — in `app/js/views/map.js` + +**Checkpoint**: typed allegiance-framed symbols render and are authorable — the MVP. + +--- + +## Phase 4: User Story 2 — Record intel confidence (Priority: P1) + +**Goal**: A planner sets each asset's confidence (high/medium/low); the map de-emphasises low-confidence assets and the roster shows a badge. + +**Independent Test**: set a red asset to low confidence → faded marker + low badge; raise to high → emphasis updates; reload → persists. + +### Tests for User Story 2 + +- [X] T012 [P] [US2] Unit tests — `confidence` round-trip + absent-default behaviour, isolation — in `test/orbat.test.mjs` +- [X] T013 [P] [US2] e2e — set low vs high confidence, assert map emphasis differs + roster badge + persistence across reload — in `e2e/orbat.spec.ts` + +### Implementation for User Story 2 + +- [X] T014 [US2] Render the per-row **confidence selector** + a roster confidence badge in `app/js/shell/orbat-panel.js` +- [X] T015 [US2] Map `confidence` → marker/glyph **opacity** (high≈1.0, medium≈0.6, low≈0.35; absent ⇒ full) in `app/js/views/map.js` + +**Checkpoint**: intel confidence is authorable and visible across map + roster. + +--- + +## Phase 5: User Story 3 — Red threat detection vs engagement reach (Priority: P2) + +**Goal**: A red asset gets two independently-tunable rings — detection (outer) and engagement (inner); green/blue keep their single extent. + +**Independent Test**: set detection > engagement on a red asset → two distinct concentric rings; try engagement > detection → reconciled with feedback; green/blue still show a single extent control + ring. + +### Tests for User Story 3 + +- [X] T016 [P] [US3] Unit tests — dual-range clamp, `engagement ≤ detection` reconciliation, red migration seed, green/blue single-extent unaffected — in `test/orbat.test.mjs` +- [X] T017 [P] [US3] e2e — two distinct red rings, engagement>detection reconciled, green/blue single ring, route unchanged — in `e2e/orbat.spec.ts` + +### Implementation for User Story 3 + +- [X] T018 [US3] Replace the single extent tuner for **red** rows with **detection** + **engagement** range tuners (single extent stays for green/blue); inline reconcile feedback — in `app/js/shell/orbat-panel.js` +- [X] T019 [US3] Draw red assets as a faint outer **detection** ring + a bold inner **engagement** ring (single `extent_m` ring retained for green/blue) in `app/js/views/map.js` + +**Checkpoint**: the visual enrichments are independently authorable and visible; everything stays display-only. + +--- + +## Phase 6: User Story 4 — Describe an asset (strength, notes, type/category/role) (Priority: P3) + +**Goal**: A planner records lightweight descriptive detail — strength, notes, and a per-allegiance descriptor (red threat type, green category, blue role) — shown in the roster. + +**Independent Test**: set strength/notes + the allegiance descriptor on one asset of each side; confirm each shows in the roster and round-trips through reload/duplicate/commit; clearing a field omits it; no routing effect. + +### Tests for User Story 4 + +- [X] T020 [P] [US4] Unit tests — `strength`/`notes`/`threat_type`/`category`/`role` round-trip through `tuneAsset`/`canonical`, trim + empty-drop, `category` vocab validation, isolation — in `test/orbat.test.mjs` +- [X] T021 [P] [US4] e2e — set strength/notes + red threat-type / green category / blue role, assert roster shows them + persistence across reload + route unchanged — in `e2e/orbat.spec.ts` + +### Implementation for User Story 4 + +- [X] T022 [US4] Render per-row **strength** + **notes** inputs and the per-allegiance descriptor control (red **threat type** text, green **category** select, blue **role** text); omit empty values from the roster display — in `app/js/shell/orbat-panel.js` +- [X] T023 [US4] Surface strength/notes/descriptor in the asset marker tooltip/label where natural (optional, display-only) in `app/js/views/map.js` + +**Checkpoint**: assets are identifiable units — symbol + confidence + ranges + descriptive detail; all display-only. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [X] T024 [P] Record project memory: new ADR (asset enrichment — kind/symbol/confidence + red dual-range + descriptive fields, display-only) in `docs/project_notes/decisions.md`; platform-kind glyph table + confidence-opacity scale + red range/descriptor fields in `docs/project_notes/key_facts.md`; work-log entry in `docs/project_notes/issues.md` +- [X] T025 [P] Author the blog post `specs/005-orbat-asset-enrichment/blog/post.md` from `docs/blog-post-template.md` (problem/options/strategy/results/screenshots + "at a glance") and add `blog/screenshots/` +- [X] T026 Run `quickstart.md` end-to-end (incl. the spec-004 draft backward-compat check) and collect evidence screenshots into `specs/005-orbat-asset-enrichment/evidence/screenshots/` +- [X] T027 Run `npm run test:unit` + `npm run test:e2e` + `npm run typecheck`; ensure `// @ts-check` is clean across the changed modules + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: schema first; T001→T002→T003→T004 sequential (same generation). +- **Foundational (Phase 2)**: depends on Setup; **blocks all user stories**. T005/T006/T007 all edit `orbat.js` — sequence them. +- **User Stories (Phase 3–6)**: all depend on Foundational. Independent of each other in behaviour, but all four touch `orbat-panel.js` (and US1–US3/US4 also `map.js`) — **coordinate/sequence those edits** (test files are parallel-safe). +- **Polish (Phase 7)**: after the desired stories are complete. + +### User Story Dependencies + +- **US1 (P1)**: after Foundational — the MVP (symbols are the foundation the picture hangs off). +- **US2 (P1)**: after Foundational — independent of US1 (confidence is orthogonal to kind). +- **US3 (P2)**: after Foundational — independent; relies on the dual-range clamp/migration from T005/T006. +- **US4 (P3)**: after Foundational — independent; the descriptive fields are pure additive model + roster. + +### Within Each User Story + +- Tests written first and FAIL before implementation. +- Model behaviour (already in Foundational) → panel wiring → map rendering. +- Story complete and independently tested before moving on. + +### Parallel Opportunities + +- Unit-test and e2e-test tasks marked [P] within a story run together (`test/orbat.test.mjs` vs `e2e/orbat.spec.ts`). +- Polish T024/T025 [P] are independent files. +- Note: US1–US4 panel + map edits are NOT parallel (same files) — coordinate or sequence. + +--- + +## Implementation Strategy + +### MVP First (User Story 1 only) + +1. Phase 1 Setup → 2. Phase 2 Foundational → 3. Phase 3 US1 (kind + symbols) → **STOP & validate** the typed-symbol picture → demo via the PR preview. + +### Incremental Delivery + +Setup + Foundational → US1 (symbols, MVP) → US2 (confidence) → US3 (red dual-range) → US4 (descriptive detail) → Polish. Each story adds value without breaking the previous ones, and all are display-only (NF9) + backward-compatible with spec-004 drafts (FR-010). + +--- + +## Notes + +- [P] = different files, no dependency on incomplete work. +- LinkML is the source of truth (Principle I): `kind`/`symbol`/`confidence` + red ranges are schema-defined and regenerated (T001–T004), never hand-authored; only the glyph lookup (`SYMBOLS`/`symbolOf`) stays in `app/js` (the UI/behaviour carve-out). +- Honest floor (NF9) + determinism (NF3) are assertable invariants — no enriched attribute touches routing/the kernel (re-asserted in T009/T017's route-unchanged check). +- Backward compatibility (FR-010) is foundational (T005) and verified in T022's spec-004 draft check. +- Commit after each task or logical group; capture evidence screenshots during e2e runs. diff --git a/test/orbat.test.mjs b/test/orbat.test.mjs new file mode 100644 index 0000000..abb97d3 --- /dev/null +++ b/test/orbat.test.mjs @@ -0,0 +1,271 @@ +// test/orbat.test.mjs — unit tests for the ORBAT model (DEC-60), spec 004. +// node --test, build-free: imports app/js/orbat/orbat.js directly (no h3-js / DOM). +// Covers determinism (NF3), per-asset isolation (SC-003), clamping (FR-004), the +// canonical-own-force reconciliation (FR-012), and commit immutability/lineage. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + emptyOrbat, addAsset, tuneAsset, duplicateAsset, removeAsset, + reconcileOwnForce, validate, canonical, commit, hasTrack, normalize, + symbolOf, confidenceOpacity, GENERIC_SYMBOL, SYMBOLS, BOUNDS, OWN_FORCE_ID, +} from '../app/js/orbat/orbat.js'; +import { ObjectStore } from '../app/js/stores/stores.js'; + +const pos = (n = 0) => ({ h3: `cell-${n}`, lat: 54.96 + n * 0.001, lng: -3.1 }); + +// --- US1: red add/tune/isolation/determinism -------------------------------- + +test('addAsset mints a fresh unique id and seeds red defaults', () => { + let o = emptyOrbat('s'); + const r1 = addAsset(o, { allegiance: 'red', position: pos(1) }); + const r2 = addAsset(r1.orbat, { allegiance: 'red', position: pos(2) }); + assert.notEqual(r1.id, r2.id); + assert.equal(r2.orbat.assets.length, 2); + const a = r2.orbat.assets.find((x) => x.id === r1.id); + assert.equal(a.allegiance, 'red'); + assert.equal(a.red.severity, 3); + assert.ok(Array.isArray(a.red.active_windows)); + assert.equal(a.extent_m > 0, true); +}); + +test('tuneAsset clamps red extent_m and severity to bounds (FR-004)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + const id = o.id; + o = tuneAsset(o.orbat, id, { extent_m: 9_999_999, red: { severity: 99 } }); + const a = o.assets.find((x) => x.id === id); + assert.equal(a.extent_m, BOUNDS.extent_m[1]); + assert.equal(a.red.severity, BOUNDS.severity[1]); + o = tuneAsset(o, id, { extent_m: -50, red: { severity: -3 } }); + const b = o.assets.find((x) => x.id === id); + assert.equal(b.extent_m, BOUNDS.extent_m[0]); + assert.equal(b.red.severity, BOUNDS.severity[0]); +}); + +test('tuning one asset leaves the others byte-identical (SC-003 isolation)', () => { + let r = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + r = addAsset(r.orbat, { allegiance: 'red', position: pos(2) }); + const [id1, id2] = r.orbat.assets.map((a) => a.id); + const before2 = JSON.stringify(r.orbat.assets.find((a) => a.id === id2)); + const next = tuneAsset(r.orbat, id1, { label: 'changed', red: { severity: 5 } }); + const after2 = JSON.stringify(next.assets.find((a) => a.id === id2)); + assert.equal(after2, before2); + assert.equal(next.assets.find((a) => a.id === id1).label, 'changed'); +}); + +test('canonical is stable and order-independent (NF3 determinism)', () => { + let a = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + a = addAsset(a.orbat, { allegiance: 'green', position: pos(2) }); + // Same content, assets inserted in the opposite order ⇒ identical canonical bytes. + let b = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + b = addAsset(b.orbat, { allegiance: 'green', position: pos(2) }); + // canonical() sorts assets by id, so reversing the array must not change the bytes. + const reordered = { ...b.orbat, assets: [...b.orbat.assets].reverse() }; + assert.equal(canonical(a.orbat), canonical(reordered)); +}); + +// --- US2: green defaults / clamp / isolation -------------------------------- + +test('green defaults + GreenParams clamp (sensitivity, protection)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'green', position: pos(1) }); + const id = o.id; + const a0 = o.orbat.assets[0]; + assert.equal(a0.green.sensitivity, 3); + assert.equal(a0.green.protection, 'keep_out'); + let next = tuneAsset(o.orbat, id, { green: { sensitivity: 42, protection: 'minimise_effect' } }); + let a = next.assets.find((x) => x.id === id); + assert.equal(a.green.sensitivity, BOUNDS.sensitivity[1]); + assert.equal(a.green.protection, 'minimise_effect'); + // An unknown protection value is ignored (stays the last valid one). + next = tuneAsset(next, id, { green: { protection: 'nonsense' } }); + assert.equal(next.assets.find((x) => x.id === id).green.protection, 'minimise_effect'); +}); + +// --- US3: blue defaults / clamp / reconciliation ---------------------------- + +test('blue defaults + BlueParams tuning (availability, capabilities)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'blue', position: pos(1) }); + const id = o.id; + assert.equal(o.orbat.assets[0].blue.availability, 'available'); + const next = tuneAsset(o.orbat, id, { blue: { availability: 'down', capabilities: ['recce', 'comms'] } }); + const a = next.assets.find((x) => x.id === id); + assert.equal(a.blue.availability, 'down'); + assert.deepEqual(a.blue.capabilities, ['recce', 'comms']); +}); + +test('blue availability_window persists through tuneAsset and projects a track', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'blue', position: pos(1) }); + const id = o.id; + assert.equal(hasTrack(o.orbat.assets[0]), false); // no window yet → no track + let next = tuneAsset(o.orbat, id, { blue: { availability_window: { start_min: 60, end_min: 30 } } }); + const a = next.assets.find((x) => x.id === id); + // Window is persisted and normalised to start ≤ end. + assert.deepEqual(a.blue.availability_window, { start_min: 30, end_min: 60 }); + assert.equal(hasTrack(a), true); + // Clearing it removes the field (and the track). + next = tuneAsset(next, id, { blue: { availability_window: undefined } }); + assert.equal(next.assets.find((x) => x.id === id).blue.availability_window, undefined); + assert.equal(hasTrack(next.assets.find((x) => x.id === id)), false); +}); + +test('reconcileOwnForce is idempotent and yields exactly one canonical own-force (FR-012)', () => { + let o = reconcileOwnForce(emptyOrbat('s'), { label: 'ROVER-1', position: pos(0) }); + o = reconcileOwnForce(o, { label: 'ROVER-1', position: pos(0) }); + const canon = o.assets.filter((a) => a.canonical_own_force); + assert.equal(canon.length, 1); + assert.equal(canon[0].id, OWN_FORCE_ID); + assert.equal(canon[0].allegiance, 'blue'); + // Reconciling again after adding pool assets keeps exactly one canonical. + o = addAsset(o, { allegiance: 'blue', position: pos(3) }).orbat; + o = reconcileOwnForce(o, { label: 'ROVER-1', position: pos(0) }); + assert.equal(o.assets.filter((a) => a.canonical_own_force).length, 1); +}); + +// --- US4: duplicate / remove / commit -------------------------------------- + +test('duplicateAsset mints a new id, deep-copies params, drops the canonical flag', () => { + let o = reconcileOwnForce(emptyOrbat('s'), { label: 'ROVER-1', position: pos(0) }); + const dup = duplicateAsset(o, OWN_FORCE_ID); + const copy = dup.orbat.assets.find((a) => a.id === dup.id); + assert.notEqual(dup.id, OWN_FORCE_ID); + assert.equal(copy.canonical_own_force, undefined); // a duplicate is never the canonical + assert.equal(copy.allegiance, 'blue'); + // Independent copy: tuning the copy does not touch the source. + const tuned = tuneAsset(dup.orbat, dup.id, { extent_m: 1234 }); + assert.equal(tuned.assets.find((a) => a.id === OWN_FORCE_ID).extent_m, + dup.orbat.assets.find((a) => a.id === OWN_FORCE_ID).extent_m); +}); + +test('removeAsset drops the target, protects the canonical own-force (FR-012)', () => { + let o = reconcileOwnForce(emptyOrbat('s'), { label: 'ROVER-1', position: pos(0) }); + const r = addAsset(o, { allegiance: 'red', position: pos(1) }); + o = r.orbat; + const after = removeAsset(o, r.id); + assert.equal(after.assets.find((a) => a.id === r.id), undefined); + assert.equal(after.assets.find((a) => a.id === OWN_FORCE_ID) !== undefined, true); + assert.throws(() => removeAsset(o, OWN_FORCE_ID), /protected/); +}); + +test('commit mints an immutable content-addressed version with lineage', async () => { + const store = new ObjectStore(); + let o = emptyOrbat('s'); + const c1 = await commit(o, store); + // Re-commit of identical content is idempotent (DEC-35). + const c1again = await commit(o, store); + assert.equal(c1.id, c1again.id); + assert.equal(c1again.existed, true); + // A changed roster, linked to the prior version, mints a new id + a 2-long lineage chain. + o = addAsset(o, { allegiance: 'red', position: pos(1) }).orbat; + o = { ...o, lineage: { previous_version: c1.id } }; + const c2 = await commit(o, store); + assert.notEqual(c2.id, c1.id); + assert.deepEqual(store.lineage(c2.id), [c2.id, c1.id]); +}); + +// --- validation ------------------------------------------------------------ + +test('validate flags an out-of-AO position and a malformed window', () => { + const inAO = (p) => p?.h3 === 'in'; + const bad = { id: 'x', allegiance: 'red', position: { h3: 'out' }, + red: { severity: 3, active_windows: [{ start_min: 90, end_min: 30 }] } }; + const v = validate(bad, { inAO }); + assert.equal(v.ok, false); + assert.ok(v.issues.some((i) => /AO/.test(i))); + assert.ok(v.issues.some((i) => /start_min/.test(i))); + const good = { id: 'y', allegiance: 'red', position: { h3: 'in' }, extent_m: 1000, red: { severity: 3, active_windows: [] } }; + assert.equal(validate(good, { inAO }).ok, true); +}); + +// --- spec 005 enrichment --------------------------------------------------- + +// US1 — kind + symbols +test('symbolOf resolves override > kind glyph > generic dot (FR-002/003)', () => { + assert.equal(symbolOf({ id: 'a', allegiance: 'red' }), GENERIC_SYMBOL); // unset → generic + assert.equal(symbolOf({ id: 'a', allegiance: 'red', kind: 'aircraft' }), SYMBOLS.aircraft); + assert.equal(symbolOf({ id: 'a', allegiance: 'red', kind: 'aircraft', symbol: '★' }), '★'); // override wins +}); + +test('kind/symbol round-trip through tuneAsset; unknown kind ignored; cleared override drops (FR-014)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + const id = o.id; + o = tuneAsset(o.orbat, id, { kind: 'emplacement', symbol: '✷' }); + let a = o.assets.find((x) => x.id === id); + assert.equal(a.kind, 'emplacement'); + assert.equal(a.symbol, '✷'); + // unknown kind is rejected (stays the last valid one) + o = tuneAsset(o, id, { kind: 'nonsense' }); + assert.equal(o.assets.find((x) => x.id === id).kind, 'emplacement'); + // clearing the override removes the field entirely + o = tuneAsset(o, id, { symbol: '' }); + assert.equal('symbol' in o.assets.find((x) => x.id === id), false); +}); + +// US2 — confidence +test('confidence round-trips and drives opacity; absent ⇒ full emphasis (FR-004)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'green', position: pos(1) }); + const id = o.id; + assert.equal(confidenceOpacity(o.orbat.assets[0]), 1); // absent → full + o = tuneAsset(o.orbat, id, { confidence: 'low' }); + const a = o.assets.find((x) => x.id === id); + assert.equal(a.confidence, 'low'); + assert.equal(confidenceOpacity(a) < 1, true); + // unknown confidence ignored + o = tuneAsset(o, id, { confidence: 'bogus' }); + assert.equal(o.assets.find((x) => x.id === id).confidence, 'low'); +}); + +// US3 — red dual range +test('red dual-range clamps and reconciles engagement ≤ detection (FR-005/006)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + const id = o.id; + // engagement set larger than detection → reconciled down to detection + o = tuneAsset(o.orbat, id, { red: { detection_range_m: 3000, engagement_range_m: 9999 } }); + let a = o.assets.find((x) => x.id === id); + assert.equal(a.red.detection_range_m, 3000); + assert.equal(a.red.engagement_range_m, 3000); // reconciled ≤ detection + // out-of-bounds detection clamps + o = tuneAsset(o, id, { red: { detection_range_m: 9_999_999 } }); + assert.equal(o.assets.find((x) => x.id === id).red.detection_range_m, BOUNDS.extent_m[1]); +}); + +test('normalize migrates a spec-004 red draft (extent_m → detection) and is idempotent (FR-010)', () => { + // A spec-004-shaped red asset: single extent, no dual range. + const legacy = { id: '', name: 's', version: 1, assets: [ + { id: 'asset-1', allegiance: 'red', extent_m: 2400, red: { severity: 4, active_windows: [] } }, + { id: 'asset-2', allegiance: 'green', extent_m: 1000, green: { sensitivity: 3, protection: 'keep_out' } }, + ], lineage: {} }; + const n1 = normalize(legacy); + const red = n1.assets.find((a) => a.id === 'asset-1'); + assert.equal(red.red.detection_range_m, 2400); // migrated from extent_m + assert.equal(red.red.engagement_range_m <= 2400, true); + // green untouched (still single extent, no dual range) + assert.equal(n1.assets.find((a) => a.id === 'asset-2').red, undefined); + // idempotent: re-normalising yields identical canonical bytes + assert.equal(canonical(n1), canonical(normalize(n1))); +}); + +// US4 — descriptive detail +test('descriptive fields round-trip, trim, and drop when empty; category vocab-checked (FR-012/013/014)', () => { + let o = addAsset(emptyOrbat('s'), { allegiance: 'red', position: pos(1) }); + const rid = o.id; + o = tuneAsset(o.orbat, rid, { strength: ' ×2 ', notes: 'dug in', red: { threat_type: ' SAM ' } }); + let r = o.assets.find((x) => x.id === rid); + assert.equal(r.strength, '×2'); // trimmed + assert.equal(r.notes, 'dug in'); + assert.equal(r.red.threat_type, 'SAM'); + // clearing notes drops the key + o = tuneAsset(o, rid, { notes: ' ' }); + assert.equal('notes' in o.assets.find((x) => x.id === rid), false); + + // green category (vocab) + blue role + let g = addAsset(o, { allegiance: 'green', position: pos(2) }); + o = tuneAsset(g.orbat, g.id, { green: { category: 'hospital' } }); + assert.equal(o.assets.find((x) => x.id === g.id).green.category, 'hospital'); + o = tuneAsset(o, g.id, { green: { category: 'not-a-category' } }); // ignored + assert.equal(o.assets.find((x) => x.id === g.id).green.category, 'hospital'); + + let b = addAsset(o, { allegiance: 'blue', position: pos(3) }); + o = tuneAsset(b.orbat, b.id, { blue: { role: 'recce' } }); + assert.equal(o.assets.find((x) => x.id === b.id).blue.role, 'recce'); +});