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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions claude-notes/plans/2026-06-23-aiwar-dual-use-value-tenant.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,44 @@ stakeholders & people fill 3; schema nodes fill none.
stakeholder packs AIDeployer|AISubject bits.
- [ ] `cargo nextest run -p cockpit-server` green.

### Phase 2 — wire + view (next increment, touches the baked asset)
- [ ] Emit the 6-byte facet tail per node on the OSO1 wire (additive, after
the label tail; old readers ignore it).
- [ ] Mirror the codebook in `OsintGraph.tsx`; decode the facet tail.
- [ ] Surface dual-use in node tooltips + the reasoning readout (the "direct
line" narration).
- [ ] Add the **AIRO-role "meta" lens**: recolour actors by game-theory role
(Subject = harm lands here, Deployer fields, Developer builds, Provider
supplies); boomerang nodes (Subject+Deployer) flagged.
- [ ] Re-bake `assets/osint_scene.soa` from the FULL harvest
(`/home/user/aiwar-neo4j-harvest` — must be the 920-node enriched
source, NOT the 221-node `public/` fallback) and verify node count
unchanged.
- [ ] `npm run build` (cockpit) green; inspect the rendered scene.
### Phase 2 — dimensions IN the schema (facet edges) ← CORRECTED 2026-06-23

**Why the original Phase 2 was wrong.** The plan was to decode the tenant into
tooltips + an AIRO lens. Operator feedback (with screenshots): "the dimensions
are still not in the schema; the family-adapter-as-model ↔ ClassView doesn't
work." Diagnosis confirmed in the harvest cypher: the 12 `SchemaAxis` + their
`SchemaValue` leaves exist, but there is **zero** edge from any entity to a
`SchemaValue` — the schema is a disconnected legend. The `0x0700` ClassView is
empty + display-only, so it can never put a dimension "in the schema" (a
ClassView renders one entity's fields on click; it does not emit shared graph
edges). The tenant bytes are a hot-scan twin, not graph structure. "In the
schema" = traversable edges.

**The fix (done):** emit `entity → SchemaValue` facet edges — the harvest's own
faceted graph (model.rs pattern #1) that its cypher never emitted.
- [x] `entity_facet_edges()` in `osint_gotham.rs`: per-axis edges (rel 10..15)
from each node's facet props to the matching `SchemaValue` (keyed by value
string); compound values split. Emitted in `osint_soa_bytes`.
- [x] `OsintGraph.tsx`: REL_NAME/REL_COLOR for 10..15; a **dimension-layer
toggle** (`◇ dimensions`) that hides cls 5/6 nodes + VALID_FOR + facet
edges via vis `hidden` (no relayout) — the "family concepts" off/on.
- [x] tests: `facet_edges_wire_entities_to_schema_values` (5 edges, compound
split, no spurious sources); logic verified standalone; `npm run build`
(cockpit) green.
- [ ] **RE-BAKE REQUIRED** — the facet edges are inert until `osint_scene.soa`
is regenerated. The bake (`cargo test -p cockpit-server --bin q2-cockpit
-- --ignored bake_osint_soa`) needs cockpit-server to compile (lance 7 +
datafusion — disk-infeasible in the dev sandbox) AND the FULL enriched
harvest at `/home/user/aiwar-neo4j-harvest` (NOT the 221-node `public/`
fallback, which has no `SchemaValue` nodes to link to). Do this on
Railway / a full-disk machine, then verify node count unchanged and the
facet edges present.

### Phase 3 — optional follow-ups
- [ ] Canonicalize the facet edges in the harvest cypher (source-side), so all
consumers get them, not just the cockpit bake.
- [ ] "Same tool" / boomerang traversal: from a `SchemaValue` walk to every
entity sharing it; flag the Deployer∩Subject nodes.

## Notes
- Source repos cloned to scratchpad: `aiwar` (Quarto site + canonical CSV),
Expand Down
45 changes: 45 additions & 0 deletions cockpit/src/OsintGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,21 @@ const ANGLES: Array<{ name: string; cls: number }> = [

// rel byte → relation name + colour (rel_code in osint_gotham.rs). 0/1 are the
// basin scaffold; 2..9 are the real neo4j relations (the VIEW we render).
// 0/1 are the basin scaffold; 2..9 the real neo4j relations; 10..15 are the
// dual-use FACET edges (entity → its SchemaValue), the dimensions made
// traversable — the toggleable "dimension layer".
const REL_NAME = [
'member-of', 'interfaces', 'CONNECTED_TO', 'DEVELOPED_BY', 'DEPLOYED_BY',
'PERSON_LINK', 'USED_IN', 'HIERARCHICAL', 'VALID_FOR', 'related',
'militaryUse', 'civicUse', 'airo:type', 'MLType', 'purpose', 'capacity',
];
const REL_COLOR = [
'#223040', '#223040', '#4dd0e1', '#ffb547', '#35d07f',
'#ff637d', '#9b8cff', '#c792ea', '#7fd1c7', '#8fa6c4',
'#ff637d', '#35d07f', '#c792ea', '#7fd1ff', '#ffb547', '#9b8cff',
];
// 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);

const DIM_NODE = { background: 'rgba(10,14,23,0.55)', border: '#26323f' };
const DIM_EDGE = 'rgba(50,66,84,0.12)';
Expand Down Expand Up @@ -81,6 +88,7 @@ interface GraphApi {
query: (text: string) => 'seed' | 'path' | 'not-found';
fireLens: (angleIdx: number) => void;
clear: () => void;
setDims: (show: boolean) => void;
}

// Decode the OSO1 wire: magic(4) | nodeCount u32 | edgeCount u32 |
Expand Down Expand Up @@ -264,6 +272,7 @@ export function OsintGraph() {
const [readout, setReadout] = useState<Readout | null>(null);
const [search, setSearch] = useState('');
const [angle, setAngle] = useState<number | null>(null);
const [showDims, setShowDims] = useState(true);

// Fetch + decode the SoA once.
useEffect(() => {
Expand Down Expand Up @@ -593,6 +602,19 @@ export function OsintGraph() {
return prefix ?? sub;
};

// the dimension layer = SchemaValue/SchemaAxis nodes (cls 5/6) + their
// VALID_FOR and facet edges. Toggle hides it via vis `hidden` (no relayout),
// so the "family concepts" can be dropped to a clean entity graph and back.
const schemaNodeIds = Array.from(touched).filter((i) => soa.cls[i] === 5 || soa.cls[i] === 6);
const schemaEdgeIds = semantic.filter((e) => isFacetRel(e.r)).map((e) => e.id);
const setDims = (show: boolean) => {
visNodes.update(schemaNodeIds.map((id) => ({ id, hidden: !show })));
visEdges.update(schemaEdgeIds.map((id) => ({ id, hidden: !show })));
};
// apply the current toggle state on (re)build — covers a toggle that landed
// before the network (and apiRef) existed, so the button and graph never desync.
setDims(showDims);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
apiRef.current = {
query: (text) => {
const parts = text.split(/[+,&]/).map((s) => s.trim()).filter(Boolean);
Expand All @@ -618,6 +640,7 @@ export function OsintGraph() {
restore();
setReadout(null);
},
setDims,
};

net.on('click', (params: { nodes: unknown[] }) => {
Expand Down Expand Up @@ -646,6 +669,11 @@ export function OsintGraph() {
setAngle(null);
apiRef.current?.clear();
};
const toggleDims = () => {
const next = !showDims;
setShowDims(next);
apiRef.current?.setDims(next);
};

const lensChip = (i: number): CSSProperties => ({
fontFamily: 'monospace',
Expand Down Expand Up @@ -742,6 +770,23 @@ export function OsintGraph() {
{a.name}
</button>
))}
<button
onClick={toggleDims}
title="show/hide the dimension layer — militaryUse · civicUse · airo:type · MLType · purpose · capacity"
style={{
fontFamily: 'monospace',
fontSize: 11,
color: showDims ? '#0a0e17' : '#9fb4c8',
background: showDims ? '#c792ea' : 'rgba(17,32,48,0.6)',
border: '1px solid #c792ea',
borderRadius: 6,
padding: '5px 9px',
cursor: 'pointer',
fontWeight: showDims ? 700 : 400,
}}
>
◇ dimensions
</button>
{readout && (
<button
onClick={clearReason}
Expand Down
108 changes: 108 additions & 0 deletions crates/cockpit-server/src/osint_gotham.rs
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,63 @@ fn push_edge(buf: &mut Vec<u8>, s: usize, t: usize, rel: u8) {
buf.push(rel);
}

// Facet edge-type codes (entity → SchemaValue, one per dual-use axis). These put
// the dimensions IN the schema — traversable — beyond the orphaned
// `SchemaValue —VALID_FOR→ SchemaAxis` legend the harvest emits. They are the
// graph-structure twin of the value tenant (value[1..=6]): the tenant is the hot
// scan, these edges are the schema. rel ≥ 10 keeps them a distinct, toggleable
// layer the client can hide ("family concepts" off).
const REL_FACET_MILITARY: u8 = 10;
const REL_FACET_CIVIC: u8 = 11;
const REL_FACET_AIRO: u8 = 12;
const REL_FACET_MLTYPE: u8 = 13;
const REL_FACET_PURPOSE: u8 = 14;
const REL_FACET_CAPACITY: u8 = 15;

/// (property-key candidates, facet rel) per dual-use axis. First matching key
/// wins (e.g. `MLTask` before `MLTasks`). Mirrors the facet tenant axes.
const FACET_AXES: &[(&[&str], u8)] = &[
(&["militaryUse"], REL_FACET_MILITARY),
(&["civicUse"], REL_FACET_CIVIC),
(&["airo:type"], REL_FACET_AIRO),
(&["MLTask", "MLTasks"], REL_FACET_MLTYPE),
(&["purpose", "purpose:vair"], REL_FACET_PURPOSE),
(&["capacity", "capacity:airo"], REL_FACET_CAPACITY),
];

/// Entity → `SchemaValue` facet edges: for each node carrying a dual-use facet
/// property, an edge to the `SchemaValue` node for each comma-split value.
/// `SchemaValue` nodes are keyed by their value string (the ingest's id), so the
/// match is exact. This is the harvest's faceted graph (model.rs pattern #1 —
/// "nodes belong to multiple overlapping taxonomies") that its cypher never
/// actually emitted: without it the schema is a disconnected legend.
fn entity_facet_edges(graph: &AiWarGraph) -> Vec<(usize, usize, u8)> {
let value_idx: HashMap<&str, usize> = graph
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.node_type == "SchemaValue")
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut out = Vec::new();
for (i, n) in graph.nodes.iter().enumerate() {
for (keys, rel) in FACET_AXES {
for key in *keys {
let Some(s) = n.properties.get(*key).and_then(|v| v.as_str()) else {
continue;
};
for tok in s.split(',') {
if let Some(&vi) = value_idx.get(tok.trim()) {
out.push((i, vi, *rel));
}
}
break; // first matching key for this axis wins
}
}
}
out
}

/// Serialize the enriched CAM SoA scene to the binary wire format above: node
/// GUIDs (decoded to xyz on the client) + class byte, then the typed link chart
/// and the member-of / interface structure as u16 index pairs. Members occupy
Expand Down Expand Up @@ -902,6 +959,11 @@ pub fn osint_soa_bytes(graph: &AiWarGraph, rounds: &[EncounterRound]) -> Vec<u8>
push_edge(&mut edges, s, t, rel_code(&e.rel_type));
}
}
// facet edges: wire each entity to the SchemaValue node for each dual-use
// facet — the dimensions IN the schema (rel ≥ 10, a toggleable layer).
for (s, t, rel) in entity_facet_edges(graph) {
push_edge(&mut edges, s, t, rel);
}
for (i, r) in rows.iter().enumerate() {
let basin = (r.key.family_v2() & 0xFF) as u8;
if let Some(&h) = hub_index.get(&basin) {
Expand Down Expand Up @@ -1462,6 +1524,52 @@ mod tests {
assert!(bytes.len() > 12 && nodes > 221, "baked the enriched SoA");
}

#[test]
fn facet_edges_wire_entities_to_schema_values() {
// A tiny faceted graph: a System + a Stakeholder, plus the SchemaValue
// nodes their facets point at. entity_facet_edges must wire each entity
// to the value(s) it carries — the dimensions IN the schema, not a legend.
let gnode = |id: &str, ty: &str, props: &[(&str, &str)]| aiwar_ingest::GraphNode {
id: id.to_string(),
label: id.to_string(),
node_type: ty.to_string(),
properties: props
.iter()
.map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
.collect(),
};
let g = AiWarGraph {
nodes: vec![
gnode(
"Lattice",
"System",
&[
("militaryUse", "Intelligence"),
("civicUse", "Policing, CrowdControl"),
],
),
gnode("Intelligence", "SchemaValue", &[]),
gnode("Policing", "SchemaValue", &[]),
gnode("CrowdControl", "SchemaValue", &[]),
gnode("Israel", "Stakeholder", &[("airo:type", "AIDeployer, AISubject")]),
gnode("AIDeployer", "SchemaValue", &[]),
gnode("AISubject", "SchemaValue", &[]),
],
edges: vec![],
};
let fe = entity_facet_edges(&g);
// System → its militaryUse value, and BOTH civicUse values (compound split).
assert!(fe.contains(&(0, 1, REL_FACET_MILITARY)), "militaryUse edge");
assert!(fe.contains(&(0, 2, REL_FACET_CIVIC)), "civicUse Policing");
assert!(fe.contains(&(0, 3, REL_FACET_CIVIC)), "civicUse CrowdControl");
// Stakeholder → BOTH airo roles (the boomerang, as two edges).
assert!(fe.contains(&(4, 5, REL_FACET_AIRO)), "airo AIDeployer");
assert!(fe.contains(&(4, 6, REL_FACET_AIRO)), "airo AISubject");
// SchemaValue nodes never source a facet edge; exactly five edges, no extras.
assert!(!fe.iter().any(|&(s, _, _)| (1..=3).contains(&s) || s >= 5));
assert_eq!(fe.len(), 5);
}

#[test]
fn soa_bytes_have_a_parseable_header() {
let g = sample();
Expand Down