From 78a4235f38f9b76547b6aad5053ad7348e420b14 Mon Sep 17 00:00:00 2001 From: "Claude (OSINT-V3 bake)" Date: Wed, 1 Jul 2026 12:20:40 +0000 Subject: [PATCH 1/5] osint: dissolve synthetic basin/schema hubs into anchor-entity tissue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The basin belongs in the node's own detail (GUID family byte + EdgeBlock mixin adapters), not in an outsourced helper node. A materialized hub — whether a SOA_HUB_CLASS basin node or a SchemaValue property-node — cannot dock as an edge; it only invites 'look up a property as a node', which fragments the graph into islands. Backend (osint_soa_bytes): - Stop emitting per-basin SOA_HUB_CLASS hub nodes; node_count = members only. - member-of / interfaces now dock to the basin's ANCHOR ENTITY (the real top-degree node that names the basin) via anchor_idx, never a synthetic hub. No self-loop on the anchor. - Drop the basin-hub label loop; remove the now-dead SOA_HUB_CLASS const. Frontend (OsintGraph.tsx): - Render entity<->entity relations only: real relations (2..7,9) PLUS basin tissue (member-of 0 / interfaces 1, now real anchor edges). Schema property-nodes (cls 5/6) and their facet spokes (VALID_FOR 8, facets 10..20) are no longer rendered — a dimension is a prefix carried ON the node (read live by the facet lens), never a node to spoke into. - Give member-of/interfaces visible tissue colours. Retrieval stays explicit-prefix: to gather a basin you filter the family prefix, you don't chase a hub. The facet lens (tenant colouring + raw-edge naming) is unaffected. --- cockpit/src/OsintGraph.tsx | 20 ++++-- crates/cockpit-server/src/osint_gotham.rs | 79 ++++++++++------------- 2 files changed, 49 insertions(+), 50 deletions(-) diff --git a/cockpit/src/OsintGraph.tsx b/cockpit/src/OsintGraph.tsx index 272ef75ab..f9e261940 100644 --- a/cockpit/src/OsintGraph.tsx +++ b/cockpit/src/OsintGraph.tsx @@ -52,7 +52,7 @@ const REL_NAME = [ 'militaryUse', 'civicUse', 'airo:type', 'MLType', 'purpose', 'capacity', ]; const REL_COLOR = [ - '#223040', '#223040', '#4dd0e1', '#ffb547', '#35d07f', + '#4a6a8c', '#3f5a78', '#4dd0e1', '#ffb547', '#35d07f', '#ff637d', '#9b8cff', '#c792ea', '#7fd1c7', '#8fa6c4', '#ff637d', '#35d07f', '#c792ea', '#7fd1ff', '#ffb547', '#9b8cff', ]; @@ -359,12 +359,24 @@ export function OsintGraph() { }; }, []); - // The semantic VIEW: only the real neo4j relations (rel ≥ 2) and the nodes - // they touch — the connected entity graph, not the schema scaffold. + // The semantic VIEW: entity↔entity relations only — the real neo4j relations + // (2..7, 9) PLUS the basin tissue (member-of 0 / interfaces 1, now docked to real + // ANCHOR entities, not synthetic hubs). Schema property-nodes (cls 5/6) and their + // facet spokes (VALID_FOR 8, facets 10..20) are NOT rendered: a dimension is a + // prefix carried ON the node (read live by the facet lens), never a node to spoke + // into. That is what dissolves the islands — no "look up a property as a node". const view = useMemo(() => { if (!soa) return null; const semantic = soa.edges - .filter((e) => e.r >= 2 && e.s < soa.nodeCount && e.t < soa.nodeCount) + .filter( + (e) => + e.s < soa.nodeCount && + e.t < soa.nodeCount && + soa.cls[e.s] < 5 && + soa.cls[e.t] < 5 && + e.r !== 8 && + e.r < 10, + ) .map((e, id) => ({ ...e, id })); const degree = new Map(); const touched = new Set(); diff --git a/crates/cockpit-server/src/osint_gotham.rs b/crates/cockpit-server/src/osint_gotham.rs index b2564e53b..645956344 100644 --- a/crates/cockpit-server/src/osint_gotham.rs +++ b/crates/cockpit-server/src/osint_gotham.rs @@ -944,12 +944,10 @@ pub fn build_osint_gotham(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Grap // node_count × [ len: u8 | utf8 name ] (label tail, node order) // The client decodes each 16-byte GUID → xyz (the `position()` logic ported to // JS); `class` drives colour; edges are u16 indices into the node array; the -// label tail names each node (members in graph.nodes order, then basin hubs). +// label tail names each node (members in graph.nodes order — no hub nodes). /// Magic header for the OSINT SoA wire buffer. pub const OSINT_SOA_MAGIC: [u8; 4] = *b"OSO1"; -/// Class sentinel marking a basin / family hub node. -const SOA_HUB_CLASS: u8 = 0xFF; /// Edge-type → 1-byte code (the client colours by this). fn rel_code(label: &str) -> u8 { @@ -1050,40 +1048,20 @@ fn entity_facet_edges(graph: &AiWarGraph) -> Vec<(usize, usize, u8)> { pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec { let plan = plan_basins(graph, rounds); let rows = osint_node_rows(graph, &plan); - let n_members = rows.len(); - - let mut basins: Vec = rows - .iter() - .map(|r| (r.key.family_v2() & 0xFF) as u8) - .collect::>() - .into_iter() - .collect(); - basins.sort_unstable(); - let hub_index: HashMap = basins - .iter() - .enumerate() - .map(|(k, &b)| (b, n_members + k)) - .collect(); - let node_count = n_members + basins.len(); + // Members ONLY — no synthetic basin/family hub nodes. The basin lives in the + // node's own detail (GUID `family` byte + the EdgeBlock mixin adapters); its + // membership is reasoned as a logical edge to the basin's ANCHOR ENTITY (a real + // node with its own docking logic), never a property-as-node hub. A materialized + // hub cannot dock as an edge — it only invites "look up a property as a node", + // the island failure mode this dissolves. Retrieval stays explicit-prefix: to + // gather a basin you filter the `family` prefix, you don't chase a hub. + let node_count = rows.len(); let mut nodes: Vec = Vec::with_capacity(node_count * 17); for r in &rows { nodes.extend_from_slice(r.key.as_bytes()); nodes.push(r.value[CLASS_ORDER_TENANT]); } - for &b in &basins { - let hub = NodeGuid::new_v2( - NodeGuid::CLASSID_OSINT, - u16::from(b >> 4), - u16::from(b & 0x0F), - 0, - 0, - u16::from(b), - 0, - ); - nodes.extend_from_slice(hub.as_bytes()); - nodes.push(SOA_HUB_CLASS); - } let idx_of: HashMap<&str, usize> = graph .nodes @@ -1091,6 +1069,14 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec .enumerate() .map(|(i, n)| (n.id.as_str(), i)) .collect(); + // basin byte → the graph index of its ANCHOR entity (the real top-degree node + // that names the basin — the prefix's representative). Members and interfaces + // dock here instead of a synthetic hub, so every edge lands on connecting tissue. + let anchor_idx: HashMap = plan + .anchor_of_basin + .iter() + .filter_map(|(&b, id)| idx_of.get(id.as_str()).map(|&i| (b, i))) + .collect(); let mut edges: Vec = Vec::new(); for e in &graph.edges { if let (Some(&s), Some(&t)) = @@ -1104,10 +1090,16 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec for (s, t, rel) in entity_facet_edges(graph) { push_edge(&mut edges, s, t, rel); } + // Basin membership as connecting tissue: dock each member to its basin's + // ANCHOR ENTITY (member-of), and to the anchor of every OTHER basin it relays + // through (interfaces). Both targets are real entities the reasoner can walk on + // — no synthetic hub, no self-loop on the anchor itself. for (i, r) in rows.iter().enumerate() { let basin = (r.key.family_v2() & 0xFF) as u8; - if let Some(&h) = hub_index.get(&basin) { - push_edge(&mut edges, i, h, rel_code("member-of")); + if let Some(&a) = anchor_idx.get(&basin) { + if a != i { + push_edge(&mut edges, i, a, rel_code("member-of")); + } } let ifaces: std::collections::BTreeSet = r .edges @@ -1118,8 +1110,10 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec .filter(|&b| b != 0 && b != basin) .collect(); for b in ifaces { - if let Some(&h) = hub_index.get(&b) { - push_edge(&mut edges, i, h, rel_code("interfaces")); + if let Some(&a) = anchor_idx.get(&b) { + if a != i { + push_edge(&mut edges, i, a, rel_code("interfaces")); + } } } } @@ -1141,14 +1135,6 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec let nm = if n.label.is_empty() { n.id.as_str() } else { n.label.as_str() }; push_label(&mut labels, nm); } - for &b in &basins { - let nm = plan - .anchor_of_basin - .get(&b) - .cloned() - .unwrap_or_else(|| format!("family {b:02x}")); - push_label(&mut labels, &nm); - } let mut out = Vec::with_capacity(12 + nodes.len() + edges.len() + labels.len()); out.extend_from_slice(&OSINT_SOA_MAGIC); @@ -1717,8 +1703,9 @@ mod tests { assert_eq!(&bytes[0..4], &OSINT_SOA_MAGIC); let nodes = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; let edges = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; - // members + at least one basin hub; size matches the fixed records. - assert!(nodes > g.node_count()); - assert_eq!(bytes.len(), 12 + nodes * 17 + edges * 5); + // members ONLY — one wire node per graph node, no synthetic hubs. + assert_eq!(nodes, g.node_count()); + // fixed header + node/edge records, then the additive label tail. + assert!(bytes.len() >= 12 + nodes * 17 + edges * 5); } } From 8c3f819ef943d9e72c419f9113b9ec5e7251d42e Mon Sep 17 00:00:00 2001 From: "Claude (OSINT-V3 bake)" Date: Wed, 1 Jul 2026 12:30:34 +0000 Subject: [PATCH 2/5] osint: emit 11-wide facet tenant tail + expandable property-filter palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the dual-use dimensions ON the node into the OSO1 wire and make them navigable: the explicit prefix you select is the query, and the graph filters to matches immediately. Backend (osint_soa_bytes): - Emit an additive tenant tail: node_count x 11 facet bytes = value[1..=11] (militaryUse, civicUse, airo:type, MLType, purpose, capacity, currentStatus, type, output, impact, stakeholder). No schema node — the dimension is a prefix carried on the node. Frontend (OsintGraph.tsx): - decode picks the tenant stride that fits (11 current / 6 legacy), exposed as soa.tenantStride; all tenant reads are stride-aware. - FACET_AXES_UI widened 6 -> 11 to match the backend axis order. - New lower-right EXPANDABLE PROPERTY PALETTE: per-axis value catalogue (value -> label + count), click a value to add/remove it from the filter. A node survives if, for every axis carrying >=1 selected value, its code is selected (AND across axes, OR within an axis); non-matches dim, edges survive only between two matches. Live ' match' count + clear. - This subsumes the single-axis facet legend and expresses the quid-pro-quo query directly: militaryUse=* + stakeholder=* + a purpose value. Note: McClelland motive (GUID2) is not yet populated in the bake, so 'filter by motivation' uses purpose:vair (the AIRO purpose dim) — the real 'why' signal in this data. The '<> dimensions' toggle is now inert (schema nodes no longer rendered); the palette replaces it. --- cockpit/src/OsintGraph.tsx | 312 +++++++++++++++++----- crates/cockpit-server/src/osint_gotham.rs | 15 +- 2 files changed, 257 insertions(+), 70 deletions(-) diff --git a/cockpit/src/OsintGraph.tsx b/cockpit/src/OsintGraph.tsx index f9e261940..070da4b33 100644 --- a/cockpit/src/OsintGraph.tsx +++ b/cockpit/src/OsintGraph.tsx @@ -59,10 +59,15 @@ const REL_COLOR = [ // rel codes that make up the dimension layer: VALID_FOR (8) + the facets (10..15). const isFacetRel = (r: number) => r === 8 || (r >= 10 && r <= 15); -// The 6 dual-use facet AXES in tenant-byte order (value[1..=6]). The SoA tenant -// tail ships one code per axis per node; the facet lens groups nodes by them -// LIVE (the dynamic/residual layer — the twin of the materialized facet edges). -const FACET_AXES_UI = ['militaryUse', 'civicUse', 'airo:type', 'MLType', 'purpose', 'capacity']; +// The dual-use facet AXES in tenant-byte order (value[1..=stride]). The SoA tenant +// tail ships one code per axis per node; the facet lens / property filter group +// nodes by them LIVE (the dynamic layer — the twin of the materialized facet edges). +// value byte = 1 + axis index. Order MUST match FACET_AXES / REL_FACET_* in +// osint_gotham.rs. The first 6 are the original dual-use pairs; 7..11 the enrichment. +const FACET_AXES_UI = [ + 'militaryUse', 'civicUse', 'airo:type', 'MLType', 'purpose', 'capacity', + 'currentStatus', 'type', 'output', 'impact', 'stakeholder', +]; // categorical palette for facet codes (code 0 = absent → dim slate). const FACET_PALETTE = [ '#4dd0e1', '#ffb547', '#35d07f', '#9b8cff', '#ff637d', '#c792ea', @@ -84,9 +89,11 @@ export interface Soa { cls: Uint8Array; edges: Array<{ s: number; t: number; r: number }>; labels: string[]; - // per-node facet tenant: 6 codes (value[1..=6]) × nodeCount, or null if the - // asset predates the tenant tail. The dynamic attribute the facet lens groups by. + // per-node facet tenant: `tenantStride` codes (value[1..=stride]) × nodeCount, or + // null if the asset predates the tenant tail. The dynamic attributes the facet + // lens / property filter group by. Stride is 11 (current) or 6 (legacy). tenants: Uint8Array | null; + tenantStride: number; // per-node global-category flag (HEEL=HIP=0xFFFF ceiling pole): 1 = cross-cutting. ceiling: Uint8Array; // per-node GUID identity field (bytes 14-15 LE) — the stable node id. @@ -120,6 +127,7 @@ interface GraphApi { clear: () => void; setDims: (show: boolean) => void; setFacet: (axis: number | null) => void; + setPropFilter: (keys: Set) => number; // → count of surviving nodes } // Decode the OSO1 wire: magic(4) | nodeCount u32 | edgeCount u32 | @@ -179,15 +187,23 @@ export function decodeSoa(buf: ArrayBuffer): Soa { off += len; } } - // optional tenant tail (OSO1 additive): node_count × 6 facet bytes (value[1..=6]). - // Old assets stop after the labels; new ones carry the per-node attribute here. + // optional tenant tail (OSO1 additive): node_count × STRIDE facet bytes + // (value[1..=STRIDE]). The current bake is 11-wide (militaryUse..stakeholder); + // a legacy asset is 6-wide. Old readers stop after the labels; here we pick the + // widest stride that fits so both assets decode. let tenants: Uint8Array | null = null; - if (off + nodeCount * 6 <= dv.byteLength) { - tenants = new Uint8Array(buf, off, nodeCount * 6); - off += nodeCount * 6; + let tenantStride = 0; + if (off + nodeCount * 11 <= dv.byteLength) { + tenantStride = 11; + } else if (off + nodeCount * 6 <= dv.byteLength) { + tenantStride = 6; + } + if (tenantStride) { + tenants = new Uint8Array(buf, off, nodeCount * tenantStride); + off += nodeCount * tenantStride; } return { - nodeCount, edgeCount, cls, edges, labels, tenants, ceiling, identity, + nodeCount, edgeCount, cls, edges, labels, tenants, tenantStride, ceiling, identity, heel: heelA, hip: hipA, twig: twigA, leaf: leafA, family: familyA, }; } @@ -336,8 +352,14 @@ export function OsintGraph() { const [search, setSearch] = useState(''); const [angle, setAngle] = useState(null); const [showDims, setShowDims] = useState(true); - // active facet lens (0..5 = a FACET_AXES_UI axis, or null = colour by class). + // active facet lens (0..N = a FACET_AXES_UI axis, or null = colour by class). const [facetAxis, setFacetAxis] = useState(null); + // property FILTER: selected "axis:code" keys — a node survives if, for every axis + // that has ≥1 selected code, its code on that axis is in the set (AND across axes, + // OR within an axis). The explicit prefix; the graph filters to matches live. + const [selected, setSelected] = useState>(() => new Set()); + const selectedRef = useRef>(selected); // mirror for the build closures + const [openAxis, setOpenAxis] = useState(null); // expanded palette axis // Fetch + decode the SoA once. useEffect(() => { @@ -401,7 +423,7 @@ export function OsintGraph() { const nodeBorder = (i: number) => { if (soa.ceiling[i]) return CEILING_COLOR; // global hubs stay prominent in every mode const ax = facetAxisRef.current; - if (ax != null && soa.tenants) return facetColor(soa.tenants[i * 6 + ax]); + if (ax != null && soa.tenants) return facetColor(soa.tenants[i * soa.tenantStride + ax]); return classColor(soa.cls[i]); }; const nodeKind = (i: number) => @@ -706,12 +728,57 @@ export function OsintGraph() { const setFacet = (axis: number | null) => { facetAxisRef.current = axis != null && soa.tenants ? axis : null; visNodes.update(Array.from(touched).map(baseNode)); + if (selectedRef.current.size) applyPropFilter(selectedRef.current); + }; + // property filter: a node survives if, for every axis carrying ≥1 selected + // code, its tenant code on that axis is selected (AND across axes, OR within an + // axis). The explicit prefix — matches stay lit, the rest dim, edges survive + // only between two matches. Returns the surviving count. + const matchesFilter = (i: number, byAxis: Map>): boolean => { + if (!soa.tenants) return true; + for (const [ax, codes] of byAxis) { + if (!codes.has(soa.tenants[i * soa.tenantStride + ax])) return false; + } + return true; }; - // apply the current toggle/lens state on (re)build — covers a toggle that - // landed before the network (and apiRef) existed, so the buttons and graph - // never desync. + const applyPropFilter = (keys: Set): number => { + selectedRef.current = keys; + const byAxis = new Map>(); + keys.forEach((k) => { + const [ax, code] = k.split(':').map(Number); + const s = byAxis.get(ax) ?? new Set(); + s.add(code); + byAxis.set(ax, s); + }); + const ids = Array.from(touched); + if (!byAxis.size) { + visNodes.update(ids.map(baseNode)); + visEdges.update(semantic.map(baseEdge)); + return ids.length; + } + const match = new Set(); + ids.forEach((i) => { + if (matchesFilter(i, byAxis)) match.add(i); + }); + visNodes.update( + ids.map((i) => + match.has(i) ? baseNode(i) : { id: i, color: DIM_NODE, font: { color: '#4a5766' } }, + ), + ); + visEdges.update( + semantic.map((e) => + match.has(e.s) && match.has(e.t) + ? baseEdge(e) + : { id: e.id, color: { color: DIM_EDGE }, width: 0.5, font: { color: 'rgba(0,0,0,0)' } }, + ), + ); + return match.size; + }; + // apply the current toggle/lens/filter state on (re)build — covers a control + // that landed before the network (and apiRef) existed, so nothing desyncs. setDims(showDims); setFacet(facetAxis); + if (selectedRef.current.size) applyPropFilter(selectedRef.current); apiRef.current = { query: (text) => { @@ -740,6 +807,7 @@ export function OsintGraph() { }, setDims, setFacet, + setPropFilter: applyPropFilter, }; net.on('click', (params: { nodes: unknown[] }) => { @@ -778,38 +846,78 @@ export function OsintGraph() { setFacetAxis(next); apiRef.current?.setFacet(next); }; + // keep the build-closure mirror in sync with the selection state. + useEffect(() => { + selectedRef.current = selected; + }, [selected]); + // toggle one "axis:code" property in/out of the filter, then re-apply live. + const toggleProp = (axis: number, code: number) => { + const key = `${axis}:${code}`; + const next = new Set(selected); + if (next.has(key)) next.delete(key); + else next.add(key); + setSelected(next); + apiRef.current?.setPropFilter(next); + }; + const clearProps = () => { + const empty = new Set(); + setSelected(empty); + apiRef.current?.setPropFilter(empty); + }; - // live legend for the active facet lens: value→count computed across every - // rendered node from the tenant column, named via the materialized facet - // edges (the two layers reinforcing each other). airo:type is a bitset, so its - // codes read as raw role-masks rather than single values. - const facetLegend = useMemo(() => { - if (!soa || !soa.tenants || facetAxis == null || !view) return null; + // property CATALOG: for every facet axis, the value-set carried ON the nodes — + // code → {label (named via the facet edges), count across rendered nodes}. This + // powers the expandable lower-right palette; selecting values filters the graph + // by that explicit prefix. airo:type is a bitset, so its codes read as role-masks. + const catalog = useMemo(() => { + if (!soa || !soa.tenants || !soa.tenantStride || !view) return null; const tenants = soa.tenants; - const axis = facetAxis; - const rel = 10 + axis; - const name = new Map(); - for (const e of soa.edges) { - if (e.r === rel && e.s < soa.nodeCount && e.t < soa.nodeCount) { - const code = tenants[e.s * 6 + axis]; - if (code !== 0 && !name.has(code)) name.set(code, soa.labels[e.t] || `code ${code}`); + const stride = soa.tenantStride; + return Array.from({ length: Math.min(stride, FACET_AXES_UI.length) }, (_, axis) => { + const rel = 10 + axis; + const name = new Map(); + for (const e of soa.edges) { + if (e.r === rel && e.s < soa.nodeCount && e.t < soa.nodeCount) { + const code = tenants[e.s * stride + axis]; + if (code !== 0 && !name.has(code)) name.set(code, soa.labels[e.t] || `code ${code}`); + } } - } - const count = new Map(); - let present = 0; + const count = new Map(); + view.touched.forEach((i) => { + const code = tenants[i * stride + axis]; + if (code !== 0) count.set(code, (count.get(code) ?? 0) + 1); + }); + const values = Array.from(count.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([code, n]) => ({ code, n, label: name.get(code) ?? `code ${code}` })); + return { axis, name: FACET_AXES_UI[axis], values }; + }).filter((a) => a.values.length); + }, [soa, view]); + + // live count of nodes surviving the current filter (AND across axes, OR within). + const matchCount = useMemo(() => { + if (!soa || !soa.tenants || !selected.size || !view) return null; + const stride = soa.tenantStride; + const byAxis = new Map>(); + selected.forEach((k) => { + const [ax, code] = k.split(':').map(Number); + const s = byAxis.get(ax) ?? new Set(); + s.add(code); + byAxis.set(ax, s); + }); + let n = 0; view.touched.forEach((i) => { - const code = tenants[i * 6 + axis]; - if (code !== 0) { - count.set(code, (count.get(code) ?? 0) + 1); - present += 1; + let ok = true; + for (const [ax, codes] of byAxis) { + if (!codes.has(soa.tenants![i * stride + ax])) { + ok = false; + break; + } } + if (ok) n += 1; }); - const rows = Array.from(count.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([code, n]) => ({ code, n, label: name.get(code) ?? `code ${code}` })); - return { rows, present, distinct: count.size }; - }, [soa, facetAxis, view]); + return n; + }, [soa, selected, view]); const lensChip = (i: number): CSSProperties => ({ fontFamily: 'monospace', @@ -972,8 +1080,11 @@ export function OsintGraph() { {readout && } - {/* facet-lens legend — the live group-by over the tenant column */} - {facetLegend && facetAxis != null && ( + {/* property palette — expandable per-axis value catalogue. Select N values + to filter the graph by that explicit prefix (AND across axes, OR within an + axis). e.g. militaryUse=* + stakeholder=* + a purpose value = the + quid-pro-quo query, live. */} + {catalog && catalog.length > 0 && (
-
- ◐ {FACET_AXES_UI[facetAxis]} · {facetLegend.present} nodes · {facetLegend.distinct} values -
- {facetLegend.rows.map((r) => ( -
+
+ + ◧ properties{matchCount != null ? ` · ${matchCount} match` : ''} + + {selected.size > 0 && ( - {r.label} - {r.n} -
- ))} + onClick={clearProps} + title="clear filter" + style={{ cursor: 'pointer', color: '#9fb4c8' }} + > + clear ✕ + + )} +
+ {catalog.map((ax) => { + const sel = ax.values.filter((v) => selected.has(`${ax.axis}:${v.code}`)).length; + const open = openAxis === ax.axis; + return ( +
+
setOpenAxis(open ? null : ax.axis)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + cursor: 'pointer', + padding: '2px 0', + color: sel ? '#6cf0ff' : '#b7c8db', + }} + > + {open ? '▾' : '▸'} + {ax.name} + + {sel ? `${sel}/` : ''} + {ax.values.length} + +
+ {open && ( +
+ {ax.values.map((v) => { + const on = selected.has(`${ax.axis}:${v.code}`); + return ( +
toggleProp(ax.axis, v.code)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + cursor: 'pointer', + whiteSpace: 'nowrap', + padding: '1px 0', + color: on ? '#eaf4ff' : '#8ba0b6', + }} + > + + + {v.label} + + {v.n} +
+ ); + })} +
+ )} +
+ ); + })}
)} diff --git a/crates/cockpit-server/src/osint_gotham.rs b/crates/cockpit-server/src/osint_gotham.rs index 645956344..51126afe4 100644 --- a/crates/cockpit-server/src/osint_gotham.rs +++ b/crates/cockpit-server/src/osint_gotham.rs @@ -1136,13 +1136,26 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec push_label(&mut labels, nm); } - let mut out = Vec::with_capacity(12 + nodes.len() + edges.len() + labels.len()); + // tenant tail (OSO1 additive): node_count × 11 facet bytes = value[1..=11], the + // dual-use dimensions carried ON each node (militaryUse..stakeholder). This is + // what lets the client filter/colour by any facet PREFIX without a schema node + // — explicit-prefix retrieval, no property-as-node hub. Old readers stop after + // the labels; new readers consume this tail (11-wide; a legacy 6-wide asset is + // still decodable — the client picks the stride that fits). + let mut tenants: Vec = Vec::with_capacity(node_count * FACET_STAKEHOLDER); + for r in &rows { + tenants.extend_from_slice(&r.value[1..=FACET_STAKEHOLDER]); + } + + let mut out = + Vec::with_capacity(12 + nodes.len() + edges.len() + labels.len() + tenants.len()); out.extend_from_slice(&OSINT_SOA_MAGIC); out.extend_from_slice(&(node_count as u32).to_le_bytes()); out.extend_from_slice(&(edge_count as u32).to_le_bytes()); out.extend_from_slice(&nodes); out.extend_from_slice(&edges); out.extend_from_slice(&labels); + out.extend_from_slice(&tenants); out } From 6811a404a19779133a4b578c95cf69064fa2a1dc Mon Sep 17 00:00:00 2001 From: "Claude (OSINT-V3 bake)" Date: Wed, 1 Jul 2026 13:29:01 +0000 Subject: [PATCH 3/5] =?UTF-8?q?osint:=20dual-use=20divergence=20lens=20?= =?UTF-8?q?=E2=80=94=20two=20orthogonal=20reasoning=20distances=20+=20McCl?= =?UTF-8?q?elland/Freud=20power=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures the model as distance during reasoning, never materialized: - CAUSALITY axis (intent->impact drift) = divergence, per capability the Jaccard distance of the impact sets between the militaryUse branch and the civicUse branch of the SAME offer (the shared pivot that makes the fork measurable). - DEMAND fork = mil vs civ count for that offer. - POWER level read straight from airo:type bits (the adjacency): P1 oral consume (Subject) / P3 phallic control-others (Deployer) / P4 genital empower-others (Developer/Provider) — McClelland nPow as Freud's gradient. nAch/nAff from intent/use labels (keyword heuristic). '◆ dual-use' lens paints every node by the causality distance of the capability it offers (cool->hot) and streams the ranked capabilities (demand fork · Δimpact · power level · motive) into the readout, with a Macht-adjacency % (how much high-divergence skews to power/nPow). Codebooks stay separate (no merge — distance measured at reason time); nothing materialized as a node or edge. Replaces the now-inert dimensions toggle. --- cockpit/src/OsintGraph.tsx | 222 +++++++++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 12 deletions(-) diff --git a/cockpit/src/OsintGraph.tsx b/cockpit/src/OsintGraph.tsx index 070da4b33..d425d2cd0 100644 --- a/cockpit/src/OsintGraph.tsx +++ b/cockpit/src/OsintGraph.tsx @@ -68,6 +68,44 @@ const FACET_AXES_UI = [ 'militaryUse', 'civicUse', 'airo:type', 'MLType', 'purpose', 'capacity', 'currentStatus', 'type', 'output', 'impact', 'stakeholder', ]; + +// ── Semantic typing of the 12-item mask (tenant index = value byte − 1) ─────── +// Reasoning measures DISTANCE along two ORTHOGONAL axes, never materialised: +// DEMAND = offer ⟷ need (does supply meet demand) +// CAUSALITY = intent ⟷ impact (how far the outcome drifted from the goal → divergence) +const AX = { + militaryUse: 0, civicUse: 1, airoRole: 2, mlType: 3, purpose: 4, + capacity: 5, currentStatus: 6, type: 7, output: 8, impact: 9, stakeholder: 10, +}; +// McClelland motive — and its adjacency to Freud's developmental gradient. +// demand (need) and intent are INHERENT in the motive: the motive is the source +// of both reasoning axes. The POWER motive (nPow) isn't flat — it's a 4-level +// control-directionality scale (Freud's psychosexual stages), and it sits +// ADJACENT to airo:type: the actor role IS the power level. +// P1 Oral "consume from others to myself" → extraction (the consumed = AISubject) +// P2 Anal "control myself" → self-control (internal systems) +// P3 Phallic "control OTHERS" → AIDeployer/AIOperator (fields the tool) +// P4 Genital "empower OTHERS to control others"→ AIDeveloper/AIProvider/AISupplier (builds it) +// airo:type bits: 0=Subject(1) 1=Deployer(2) 2=Developer(4) 3=Provider(8) +// 4=Operator(16) 5=Supplier(32). +const MOTIVE = ['nPow', 'nAch', 'nAff']; +const POWER_LEVEL = ['—', 'P1·oral·consume', 'P2·anal·self', 'P3·phallic·control-others', 'P4·genital·empower']; +// Power level (0..4) read straight from the airo:type bitset — the adjacency. +// The boomerang (Deployer ∧ Subject) is P3 that has become P1's object. +const powerOfAiro = (bits: number): number => { + if (bits & (4 | 8 | 32)) return 4; // Developer | Provider | Supplier — empower others + if (bits & (2 | 16)) return 3; // Deployer | Operator — control others + if (bits & 1) return 1; // Subject — the consumed + return 0; +}; +// nAch / nAff still come from the intent/use LABELS (keyword heuristic); nPow is +// carried by the power level above. +const MOTIVE_KEYS = [ + /risk|offend|criminal|detect|monitor|surveil|control|identif|backgroundcheck|lie|privacy|freedom|power|escalat|policy|weapon|command|intel|target/i, + /evaluat|candidate|performance|predict|mapping|recommend|assess|optimi|rank|classif|generat|research|achiev/i, + /advertis|social|welfare|assist|chat|consumer|market|delivery|smart|game|connect|translat|affili/i, +]; + // categorical palette for facet codes (code 0 = absent → dim slate). const FACET_PALETTE = [ '#4dd0e1', '#ffb547', '#35d07f', '#9b8cff', '#ff637d', '#c792ea', @@ -128,6 +166,7 @@ interface GraphApi { setDims: (show: boolean) => void; setFacet: (axis: number | null) => void; setPropFilter: (keys: Set) => number; // → count of surviving nodes + heatNodes: (heat: Map | null) => void; // divergence heat overlay } // Decode the OSO1 wire: magic(4) | nodeCount u32 | edgeCount u32 | @@ -351,7 +390,7 @@ export function OsintGraph() { const [readout, setReadout] = useState(null); const [search, setSearch] = useState(''); const [angle, setAngle] = useState(null); - const [showDims, setShowDims] = useState(true); + const showDims = true; // dimension-layer scaffold is inert now (schema nodes de-rendered) // active facet lens (0..N = a FACET_AXES_UI axis, or null = colour by class). const [facetAxis, setFacetAxis] = useState(null); // property FILTER: selected "axis:code" keys — a node survives if, for every axis @@ -360,6 +399,7 @@ export function OsintGraph() { const [selected, setSelected] = useState>(() => new Set()); const selectedRef = useRef>(selected); // mirror for the build closures const [openAxis, setOpenAxis] = useState(null); // expanded palette axis + const [divOn, setDivOn] = useState(false); // dual-use divergence lens active // Fetch + decode the SoA once. useEffect(() => { @@ -774,6 +814,28 @@ export function OsintGraph() { ); return match.size; }; + // heat overlay: colour each touched node by a [0,1] score (null = restore to + // base). Used by the dual-use divergence lens — the causality distance of the + // capability the node offers, painted cool→hot. + const heatNodes = (heat: Map | null) => { + const ids = Array.from(touched); + if (!heat) { + visNodes.update(ids.map(baseNode)); + return; + } + visNodes.update( + ids.map((i) => { + const t = heat.get(i); + if (t == null) return { id: i, color: DIM_NODE, font: { color: '#4a5766' } }; + const col = `rgb(${Math.round(70 + 185 * t)},${Math.round(205 - 155 * t)},${Math.round(255 - 210 * t)})`; + return { + id: i, + color: { background: 'rgba(10,14,23,0.92)', border: col }, + font: { color: '#eaf4ff' }, + }; + }), + ); + }; // apply the current toggle/lens/filter state on (re)build — covers a control // that landed before the network (and apiRef) existed, so nothing desyncs. setDims(showDims); @@ -808,6 +870,7 @@ export function OsintGraph() { setDims, setFacet, setPropFilter: applyPropFilter, + heatNodes, }; net.on('click', (params: { nodes: unknown[] }) => { @@ -834,12 +897,39 @@ export function OsintGraph() { }; const clearReason = () => { setAngle(null); + setDivOn(false); + apiRef.current?.heatNodes(null); apiRef.current?.clear(); }; - const toggleDims = () => { - const next = !showDims; - setShowDims(next); - apiRef.current?.setDims(next); + // dual-use divergence lens: paint every node by the causality distance of the + // capability it offers, and stream the ranked capabilities (demand fork · + // Δimpact · power level · motive) into the readout. Toggle off to restore. + const fireDivergence = () => { + if (divOn) { + setDivOn(false); + apiRef.current?.heatNodes(null); + setReadout(null); + return; + } + if (!duModel) return; + setDivOn(true); + setAngle(null); + apiRef.current?.heatNodes(duModel.nodeDiv); + const lines = duModel.rows.slice(0, 40).map((r) => ({ + text: `${r.label} · mil ${r.mil}/civ ${r.civ} · Δimpact ${r.jac.toFixed(2)}${ + r.pow ? ` · ${POWER_LEVEL[r.pow]}` : '' + }${r.motive >= 0 ? ` · ${MOTIVE[r.motive]}` : ''}`, + conf: r.divergence, + survived: r.divergence >= 0.5, + })); + const hi = duModel.rows.filter((r) => r.divergence >= 0.5); + const macht = hi.length ? hi.filter((r) => r.motive === 0 || r.pow >= 3).length / hi.length : 0; + setReadout({ + seed: `dual-use divergence · Macht-adjacency ${Math.round(macht * 100)}%`, + kind: 'spread', + lines, + theories: hi.length, + }); }; const toggleFacet = (axis: number) => { const next = facetAxis === axis ? null : axis; @@ -919,6 +1009,114 @@ export function OsintGraph() { return n; }, [soa, selected, view]); + // dual-use reasoning model — the two orthogonal distances + the McClelland/Freud + // power flow, all measured over the tenant (nothing materialised). + // CAUSALITY distance = intent→impact drift, per capability = the divergence, + // computed as the Jaccard distance of the impact sets between the militaryUse + // branch and the civicUse branch of the SAME offer (the shared pivot). + // DEMAND fork = mil vs civ count for that offer. + // POWER level = airo:type bits (Freud gradient), the adjacency to nPow. + const duModel = useMemo(() => { + if (!soa || !soa.tenants || !soa.tenantStride || !view) return null; + const T = soa.tenants; + const stride = soa.tenantStride; + // naming per axis (code → label) from the facet edges still in the wire. + const nmeth = new Map>(); + for (let ax = 0; ax < Math.min(stride, FACET_AXES_UI.length); ax++) { + const m = new Map(); + const rel = 10 + ax; + for (const e of soa.edges) { + if (e.r === rel && e.s < soa.nodeCount && e.t < soa.nodeCount) { + const code = T[e.s * stride + ax]; + if (code && !m.has(code)) m.set(code, soa.labels[e.t] || `code ${code}`); + } + } + nmeth.set(ax, m); + } + const nameOf = (ax: number, code: number) => nmeth.get(ax)?.get(code) ?? ''; + const powerOf = (i: number) => powerOfAiro(T[i * stride + AX.airoRole]); + const motiveOf = (i: number): number => { + if (powerOf(i) > 0) return 0; // nPow is carried by the power level + const txt = [ + nameOf(AX.purpose, T[i * stride + AX.purpose]), + nameOf(AX.militaryUse, T[i * stride + AX.militaryUse]), + nameOf(AX.civicUse, T[i * stride + AX.civicUse]), + nameOf(AX.impact, T[i * stride + AX.impact]), + ].join(' '); + let best = -1; + let hi = 0; + MOTIVE_KEYS.forEach((re, mi) => { + const c = (txt.match(re) || []).length; + if (c > hi) { + hi = c; + best = mi; + } + }); + return best; + }; + const byCap = new Map< + number, + { mil: Set; civ: Set; milImp: Set; civImp: Set; pow: number[]; mot: Map } + >(); + view.touched.forEach((i) => { + const c = T[i * stride + AX.capacity]; + if (!c) return; + const mil = T[i * stride + AX.militaryUse] !== 0; + const civ = T[i * stride + AX.civicUse] !== 0; + if (!mil && !civ) return; + let r = byCap.get(c); + if (!r) { + r = { mil: new Set(), civ: new Set(), milImp: new Set(), civImp: new Set(), pow: [], mot: new Map() }; + byCap.set(c, r); + } + const imp = T[i * stride + AX.impact]; + if (mil) { + r.mil.add(i); + if (imp) r.milImp.add(imp); + } + if (civ) { + r.civ.add(i); + if (imp) r.civImp.add(imp); + } + const p = powerOf(i); + if (p) r.pow.push(p); + const mo = motiveOf(i); + if (mo >= 0) r.mot.set(mo, (r.mot.get(mo) ?? 0) + 1); + }); + let rows = Array.from(byCap.entries()) + .map(([code, r]) => { + const mil = r.mil.size; + const civ = r.civ.size; + const forked = mil > 0 && civ > 0; + const balance = forked ? Math.min(mil, civ) / Math.max(mil, civ) : 0; + const uni = new Set([...r.milImp, ...r.civImp]); + const inter = [...r.milImp].filter((x) => r.civImp.has(x)).length; + const jac = uni.size ? 1 - inter / uni.size : 0; // causality distance + const divergence = forked ? balance * (0.4 + 0.6 * jac) : 0; + const pow = r.pow.length ? Math.round(r.pow.reduce((a, b) => a + b, 0) / r.pow.length) : 0; + let dom = -1; + let dv = 0; + r.mot.forEach((n, m) => { + if (n > dv) { + dv = n; + dom = m; + } + }); + return { code, label: nameOf(AX.capacity, code) || `cap ${code}`, mil, civ, jac, divergence, pow, motive: dom }; + }) + .filter((r) => r.divergence > 0); + const max = rows.reduce((m, r) => Math.max(m, r.divergence), 0) || 1; + rows = rows.map((r) => ({ ...r, divergence: r.divergence / max })).sort((a, b) => b.divergence - a.divergence); + const capDiv = new Map(); + rows.forEach((r) => capDiv.set(r.code, r.divergence)); + const nodeDiv = new Map(); + view.touched.forEach((i) => { + const d = capDiv.get(T[i * stride + AX.capacity]); + if (d != null) nodeDiv.set(i, d); + }); + return { rows, nodeDiv }; + }, [soa, view]); + const lensChip = (i: number): CSSProperties => ({ fontFamily: 'monospace', fontSize: 11, @@ -1015,21 +1213,21 @@ export function OsintGraph() { ))} {readout && (