Fleet topology epidemiology: strain plague map, interrupt fixes, tests.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 09:20:30 -07:00
parent f0fe34698c
commit 0935756e5f
10 changed files with 1451 additions and 89 deletions

View File

@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest';
import type { Agent } from '../types';
import {
spreadStrainFromJoinLane,
resolveAgentStrain,
miningContinuity,
buildEpidemiologyGraph,
buildMiningInterruptState,
computeGoalPath,
layoutStrainNodes,
} from './fleetTopologyEpidemiology';
function mkAgent(overrides: Partial<Agent> & { id: string }): Agent {
return {
name: overrides.id,
wallet: '4' + 'A'.repeat(94),
ip: '10.0.0.1',
version: '1.0.0',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: new Date().toISOString(),
created_at: new Date().toISOString(),
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
...overrides,
} as Agent;
}
describe('spreadStrainFromJoinLane', () => {
it('matches Go genealogy color for winrm', () => {
expect(spreadStrainFromJoinLane('winrm')).toBe('#ab88e4');
});
it('returns empty for blank lane', () => {
expect(spreadStrainFromJoinLane('')).toBe('');
});
});
describe('buildEpidemiologyGraph', () => {
it('aggregates hosts into strain nodes not per-agent nodes', () => {
const strain = '#aabbcc';
const graph = buildEpidemiologyGraph([
mkAgent({ id: 'a1', spread_strain: strain, join_lane: 'winrm' }),
mkAgent({ id: 'a2', spread_strain: strain, join_lane: 'winrm' }),
mkAgent({ id: 'a3', spread_strain: '#112233', join_lane: 'smb' }),
]);
expect(graph.nodes).toHaveLength(2);
const winrm = graph.nodes.find((n) => n.id === strain);
expect(winrm?.hostCount).toBe(2);
});
it('weights edges by successful parent spreads only', () => {
const parentStrain = '#parent';
const childStrain = '#child';
const graph = buildEpidemiologyGraph([
mkAgent({ id: 'parent', spread_strain: parentStrain, spread_generation: 0 }),
mkAgent({
id: 'child-ok',
spread_strain: childStrain,
parent_agent_id: 'parent',
join_lane: 'winrm',
spread_generation: 1,
}),
mkAgent({
id: 'child-fail',
spread_strain: childStrain,
parent_agent_id: 'parent',
status: 'offline',
spread_generation: 1,
}),
]);
const edge = graph.edges.find((e) => e.sourceStrain === parentStrain && e.targetStrain === childStrain);
expect(edge?.weight).toBe(1);
expect(edge?.failedWeight).toBe(1);
expect(edge?.branchStatus).toBe('mixed');
});
it('does not emit cure edges — only spread propagation', () => {
const graph = buildEpidemiologyGraph([
mkAgent({ id: 'a1', spread_strain: '#aa0000' }),
mkAgent({ id: 'a2', spread_strain: '#00aa00', status: 'offline' }),
]);
for (const e of graph.edges) {
expect(e.weight).toBeGreaterThanOrEqual(0);
expect(e.id).not.toMatch(/cure|heal|recover/i);
}
});
it('computes goal path toward continuous mining strains', () => {
const root = '#root';
const mid = '#mid';
const leaf = '#leaf';
const graph = buildEpidemiologyGraph([
mkAgent({
id: 'root',
spread_strain: root,
spread_generation: 0,
hashrate_15m: 0,
chain_exhausted: true,
}),
mkAgent({
id: 'mid',
spread_strain: mid,
parent_agent_id: 'root',
join_lane: 'smb',
spread_generation: 1,
}),
mkAgent({
id: 'leaf',
spread_strain: leaf,
parent_agent_id: 'mid',
join_lane: 'winrm',
spread_generation: 2,
hashrate_15m: 500,
active_method: 'container',
mining_hashrate: 500,
}),
]);
expect(graph.goalPath).toContain(leaf);
expect(graph.goalPath[0]).toBe(root);
});
});
describe('miningContinuity', () => {
it('flags interrupted mining with failed cascade', () => {
const agent = mkAgent({
id: 'stuck',
chain_exhausted: true,
failed_methods: [{ method: 'wsl', reason: 'no distro', at: '2026-06-07T00:00:00Z' }],
});
expect(miningContinuity(agent)).toBe('interrupted');
expect(buildMiningInterruptState(agent)?.agentId).toBe('stuck');
});
it('treats active hashrate as continuous', () => {
const agent = mkAgent({
id: 'ok',
hashrate_15m: 200,
active_method: 'cpu_inprocess',
});
expect(miningContinuity(agent)).toBe('continuous');
expect(buildMiningInterruptState(agent)).toBeNull();
});
});
describe('layoutStrainNodes', () => {
it('returns stable positions per strain id', () => {
const nodes = buildEpidemiologyGraph([
mkAgent({ id: 'a1', spread_strain: '#111111' }),
mkAgent({ id: 'a2', spread_strain: '#222222' }),
]).nodes;
const a = layoutStrainNodes(nodes);
const b = layoutStrainNodes(nodes);
expect(a).toEqual(b);
});
});
describe('resolveAgentStrain', () => {
it('prefers spread_strain watermark then join lane', () => {
expect(resolveAgentStrain(mkAgent({ id: 'x', spread_strain: '#ff00aa' }))).toBe('#ff00aa');
expect(resolveAgentStrain(mkAgent({ id: 'y', join_lane: 'dns_txt' }))).toBe(
spreadStrainFromJoinLane('dns_txt'),
);
});
});
describe('computeGoalPath', () => {
it('returns empty when no continuous strains', () => {
const nodes = buildEpidemiologyGraph([
mkAgent({ id: 'a', spread_strain: '#111', chain_exhausted: true }),
]).nodes;
expect(computeGoalPath(nodes, [])).toEqual([]);
});
});

View File

@@ -0,0 +1,394 @@
/**
* Fleet topology epidemiology — strains not hosts.
*
* Pure logic for the 3D plague map: nodes = spread strains, edges = successful
* parent→child spread weight. No cure edges — only propagation.
*/
import type { Agent } from '../types';
export type MiningContinuity = 'continuous' | 'interrupted' | 'dormant' | 'offline';
export type BranchStatus = 'success' | 'failed' | 'mixed' | 'dormant';
export interface MiningInterruptState {
agentId: string;
strain: string;
joinLane?: string;
spreadGeneration: number;
activeMethod?: string;
chainExhausted?: boolean;
failedMethods?: { method: string; reason: string; at: string }[];
lastError?: string;
lotlTier?: string;
miningHashrate: number;
parentAgentId?: string;
}
export interface StrainNode {
id: string;
label: string;
color: string;
hostCount: number;
onlineCount: number;
spreadGeneration: number;
miningContinuity: MiningContinuity;
branchStatus: BranchStatus;
successfulSpreads: number;
failedSpreads: number;
totalHashrate: number;
joinLane?: string;
/** Agents on this strain for interrupt reporting. */
agentIds: string[];
}
export interface StrainEdge {
id: string;
sourceStrain: string;
targetStrain: string;
weight: number;
failedWeight: number;
branchStatus: BranchStatus;
onGoalPath: boolean;
}
export interface EpidemiologyGraph {
nodes: StrainNode[];
edges: StrainEdge[];
goalPath: string[];
interrupted: MiningInterruptState[];
rootStrain?: string;
}
const WILDTYPE_STRAIN = '#334455';
const STRAIN_CAP = 80;
/** SHA-256 (browser-safe) — matches Go genealogy strain color. */
function sha256Bytes(message: Uint8Array): Uint8Array {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const len = message.length;
const bitLen = len * 8;
const padLen = ((len + 9 + 63) & ~63);
const padded = new Uint8Array(padLen);
padded.set(message);
padded[len] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(padLen - 4, bitLen >>> 0, false);
view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000), false);
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
const w = new Uint32Array(64);
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, hh = h7;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
hh = g; g = f; f = e; e = (d + t1) >>> 0;
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + hh) >>> 0;
}
const out = new Uint8Array(32);
const outView = new DataView(out.buffer);
outView.setUint32(0, h0, false); outView.setUint32(4, h1, false);
outView.setUint32(8, h2, false); outView.setUint32(12, h3, false);
outView.setUint32(16, h4, false); outView.setUint32(20, h5, false);
outView.setUint32(24, h6, false); outView.setUint32(28, h7, false);
return out;
}
/** Stable #RRGGBB strain color from join lane (Go parity). */
export function spreadStrainFromJoinLane(lane: string): string {
const normalized = lane.trim().toLowerCase();
if (!normalized) return '';
const bytes = new TextEncoder().encode(`aetherforge-strain:${normalized}`);
const digest = sha256Bytes(bytes);
const hex = (b: number) => b.toString(16).padStart(2, '0');
return `#${hex(digest[0])}${hex(digest[1])}${hex(digest[2])}`;
}
export function resolveAgentStrain(agent: Agent): string {
const baked = agent.spread_strain?.trim().toLowerCase();
if (baked) return baked;
const fromLane = spreadStrainFromJoinLane(agent.join_lane ?? '');
if (fromLane) return fromLane;
return WILDTYPE_STRAIN;
}
export function strainLabel(strain: string, joinLane?: string, generation?: number): string {
if (joinLane) return joinLane;
if (generation && generation > 0) return `gen-${generation}`;
if (strain === WILDTYPE_STRAIN) return 'wildtype';
return strain.replace(/^#/, 'strain-');
}
export function miningContinuity(agent: Agent): MiningContinuity {
if (agent.status !== 'online') return 'offline';
if (agent.fleet_role === 'seeder') return 'dormant';
const hr = agent.mining_hashrate ?? agent.hashrate_15m ?? 0;
const gpuHr = agent.gpu_hashrate_15m ?? 0;
const hashing = hr > 0 || gpuHr > 0;
if (hashing && (agent.active_method || agent.gpu_miner_active)) return 'continuous';
if (
agent.chain_exhausted ||
(agent.failed_methods?.length ?? 0) > 0 ||
agent.last_error ||
(agent.lotl_attempts?.some((a) => !a.ok) ?? false)
) {
return 'interrupted';
}
if (agent.status === 'online' && !hashing) return 'interrupted';
return 'dormant';
}
export function buildMiningInterruptState(agent: Agent): MiningInterruptState | null {
const continuity = miningContinuity(agent);
if (continuity !== 'interrupted') return null;
return {
agentId: agent.id,
strain: resolveAgentStrain(agent),
joinLane: agent.join_lane,
spreadGeneration: agent.spread_generation ?? 0,
activeMethod: agent.active_method,
chainExhausted: agent.chain_exhausted,
failedMethods: agent.failed_methods,
lastError: agent.last_error,
lotlTier: agent.lotl_tier,
miningHashrate: agent.mining_hashrate ?? agent.hashrate_15m ?? 0,
parentAgentId: agent.parent_agent_id,
};
}
function isSuccessfulSpread(child: Agent): boolean {
if (!child.parent_agent_id) return false;
return child.status === 'online' && (!!child.join_lane || child.spread_generation !== undefined);
}
function isFailedSpread(child: Agent): boolean {
if (!child.parent_agent_id) return false;
return child.status === 'error' || child.status === 'offline';
}
function branchStatusFromCounts(success: number, failed: number, online: number): BranchStatus {
if (success === 0 && failed === 0 && online === 0) return 'dormant';
if (success > 0 && failed === 0) return 'success';
if (failed > 0 && success === 0) return 'failed';
return 'mixed';
}
function stableHash(seed: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return (h % 10000) / 10000;
}
export interface StrainLayout {
strainId: string;
position: [number, number, number];
}
/** Place strain nodes on a stable spherical shell. */
export function layoutStrainNodes(nodes: StrainNode[]): StrainLayout[] {
const goldenRatio = (1 + Math.sqrt(5)) / 2;
return nodes.map((node, i) => {
const angle = i * Math.PI * 2 * goldenRatio;
const jitter = stableHash(node.id);
const radius = 3.5 + jitter * 2.5 + Math.min(node.hostCount, 20) * 0.08;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const y = (stableHash(node.id + ':y') - 0.5) * 5;
return { strainId: node.id, position: [x, y, z] as [number, number, number] };
});
}
/** BFS from root strains toward continuous-mining strains — goal path for visualization. */
export function computeGoalPath(nodes: StrainNode[], edges: StrainEdge[]): string[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const continuous = nodes.filter((n) => n.miningContinuity === 'continuous').map((n) => n.id);
if (continuous.length === 0) return [];
const adj = new Map<string, { target: string; weight: number }[]>();
for (const e of edges) {
if (e.weight <= 0) continue;
if (!adj.has(e.sourceStrain)) adj.set(e.sourceStrain, []);
adj.get(e.sourceStrain)!.push({ target: e.targetStrain, weight: e.weight });
}
const roots = nodes
.filter((n) => n.spreadGeneration === 0 || n.id === WILDTYPE_STRAIN)
.map((n) => n.id);
if (roots.length === 0) roots.push(...nodes.map((n) => n.id));
let bestPath: string[] = [];
let bestScore = -1;
for (const root of roots) {
const queue: { id: string; path: string[]; score: number }[] = [{ id: root, path: [root], score: 0 }];
const seen = new Set<string>([root]);
while (queue.length > 0) {
const cur = queue.shift()!;
if (continuous.includes(cur.id)) {
const node = byId.get(cur.id);
const bonus = (node?.totalHashrate ?? 0) + cur.score;
if (bonus > bestScore) {
bestScore = bonus;
bestPath = cur.path;
}
}
for (const next of adj.get(cur.id) ?? []) {
if (seen.has(next.target)) continue;
seen.add(next.target);
queue.push({
id: next.target,
path: [...cur.path, next.target],
score: cur.score + next.weight,
});
}
}
}
return bestPath;
}
/**
* Build strain epidemiology graph from fleet agents.
* Nodes aggregate hosts per strain; edges count successful parent→child spreads only.
*/
export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
const agentsById = new Map(agents.map((a) => [a.id, a]));
const strainBuckets = new Map<string, Agent[]>();
for (const agent of agents) {
const strain = resolveAgentStrain(agent);
if (!strainBuckets.has(strain)) strainBuckets.set(strain, []);
strainBuckets.get(strain)!.push(agent);
}
const nodes: StrainNode[] = [];
for (const [strain, hosts] of strainBuckets) {
const online = hosts.filter((h) => h.status === 'online');
const continuityRank = (c: MiningContinuity) =>
c === 'continuous' ? 4 : c === 'interrupted' ? 3 : c === 'dormant' ? 2 : 1;
let bestContinuity: MiningContinuity = 'offline';
for (const h of hosts) {
const c = miningContinuity(h);
if (continuityRank(c) > continuityRank(bestContinuity)) bestContinuity = c;
}
const successSpreads = hosts.filter(isSuccessfulSpread).length;
const failedSpreads = hosts.filter(isFailedSpread).length;
const joinLane = hosts.find((h) => h.join_lane)?.join_lane;
const maxGen = Math.max(0, ...hosts.map((h) => h.spread_generation ?? 0));
nodes.push({
id: strain,
label: strainLabel(strain, joinLane, maxGen),
color: strain.startsWith('#') ? strain : `#${strain}`,
hostCount: hosts.length,
onlineCount: online.length,
spreadGeneration: maxGen,
miningContinuity: bestContinuity,
branchStatus: branchStatusFromCounts(successSpreads, failedSpreads, online.length),
successfulSpreads: successSpreads,
failedSpreads,
totalHashrate: hosts.reduce(
(sum, h) => sum + (h.mining_hashrate ?? h.hashrate_15m ?? 0),
0,
),
joinLane,
agentIds: hosts.map((h) => h.id),
});
}
nodes.sort((a, b) => b.hostCount - a.hostCount);
const capped = nodes.length > STRAIN_CAP;
const displayNodes = capped ? nodes.slice(0, STRAIN_CAP) : nodes;
const displayStrains = new Set(displayNodes.map((n) => n.id));
const edgeMap = new Map<string, StrainEdge>();
for (const child of agents) {
if (!child.parent_agent_id) continue;
const parent = agentsById.get(child.parent_agent_id);
if (!parent) continue;
const source = resolveAgentStrain(parent);
const target = resolveAgentStrain(child);
if (!displayStrains.has(source) || !displayStrains.has(target)) continue;
const key = `${source}${target}`;
let edge = edgeMap.get(key);
if (!edge) {
edge = {
id: key,
sourceStrain: source,
targetStrain: target,
weight: 0,
failedWeight: 0,
branchStatus: 'dormant',
onGoalPath: false,
};
edgeMap.set(key, edge);
}
if (isSuccessfulSpread(child)) edge.weight += 1;
else if (isFailedSpread(child)) edge.failedWeight += 1;
}
const edges = [...edgeMap.values()].map((e) => ({
...e,
branchStatus: branchStatusFromCounts(e.weight, e.failedWeight, e.weight),
}));
const goalPath = computeGoalPath(displayNodes, edges);
const goalSet = new Set(goalPath);
for (const e of edges) {
e.onGoalPath = goalSet.has(e.sourceStrain) && goalSet.has(e.targetStrain);
}
const interrupted = agents
.map(buildMiningInterruptState)
.filter((s): s is MiningInterruptState => s !== null);
const rootStrain =
displayNodes.find((n) => n.spreadGeneration === 0)?.id ?? displayNodes[0]?.id;
return {
nodes: displayNodes,
edges,
goalPath,
interrupted,
rootStrain,
};
}
export function nodeEmissiveIntensity(node: StrainNode): number {
if (node.branchStatus === 'success' || node.miningContinuity === 'continuous') return 2.2;
if (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted') return 0.25;
if (node.branchStatus === 'mixed') return 1.0;
return 0.6;
}
export function nodeWireOpacity(node: StrainNode): number {
if (node.branchStatus === 'failed') return 0.2;
if (node.miningContinuity === 'interrupted') return 0.35;
return 0.85;
}