Add Strain Hospice for graceful low-win strain retirement.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Archive failed epidemiology strains to museum hospice with SQLite persistence, operator/AI/court triggers, breeding and graft guards, topology museum nodes, and Seer plus oath ledger accountability.
This commit is contained in:
AetherForge
2026-06-07 09:26:39 -07:00
parent 894b7a50ae
commit d605ef4adb
18 changed files with 701 additions and 18 deletions

View File

@@ -53,10 +53,11 @@ function StrainNodeMesh({
onGoalPath: boolean;
}) {
const pulseRef = useRef<THREE.Mesh>(null);
const isSuccess = node.branchStatus === 'success' || node.miningContinuity === 'continuous';
const isFailed = node.branchStatus === 'failed' || node.miningContinuity === 'interrupted';
const isMuseum = node.inHospice === true;
const isSuccess = !isMuseum && (node.branchStatus === 'success' || node.miningContinuity === 'continuous');
const isFailed = !isMuseum && (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted');
const baseColor = node.color;
const emissive = isFailed ? '#331111' : baseColor;
const emissive = isMuseum ? '#2a2a30' : isFailed ? '#331111' : baseColor;
const intensity = onGoalPath ? nodeEmissiveIntensity(node) * 1.4 : nodeEmissiveIntensity(node);
const radius = 0.35 + Math.min(node.hostCount, 12) * 0.04;
const opacity = nodeWireOpacity(node);
@@ -114,7 +115,7 @@ function StrainEdgeLine({
return (
<group>
<Line points={[start, end]} color={color} lineWidth={lineWidth} transparent opacity={opacity} />
{success && !failed && (
{success && !failed && edge.weight > 0 && (
<PlaguePulse
start={start}
end={end}
@@ -126,8 +127,17 @@ function StrainEdgeLine({
);
}
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
const graph = useMemo(() => buildEpidemiologyGraph(agents), [agents]);
export default function FleetTopologyMap({
agents,
hospiceStrains,
}: {
agents: Agent[];
hospiceStrains?: string[];
}) {
const graph = useMemo(
() => buildEpidemiologyGraph(agents, hospiceStrains),
[agents, hospiceStrains],
);
const layouts = useMemo(() => layoutStrainNodes(graph.nodes), [graph.nodes]);
const positionMap = useMemo(() => {
const map = new Map<string, [number, number, number]>();
@@ -138,6 +148,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
}, [layouts]);
const goalSet = useMemo(() => new Set(graph.goalPath), [graph.goalPath]);
const museumStrains = graph.nodes.filter((n) => n.inHospice).length;
const plagueEdges = graph.edges.filter((e) => e.weight > 0).length;
const continuousStrains = graph.nodes.filter((n) => n.miningContinuity === 'continuous').length;
const interrupted = graph.interrupted.length;
@@ -171,6 +182,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
>
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }} />
STRAIN_EPIDEMIOLOGY // {graph.nodes.length} STRAINS · {plagueEdges} PLAGUE_EDGES
{museumStrains > 0 && ` · ${museumStrains} MUSEUM`}
{graph.goalPath.length > 0 && ` · GOAL_PATH ${graph.goalPath.length}`}
{continuousStrains > 0 && ` · ${continuousStrains} MINING`}
{interrupted > 0 && ` · ${interrupted} INTERRUPTED`}

View File

@@ -57,6 +57,27 @@ describe('buildEpidemiologyGraph', () => {
expect(winrm?.hostCount).toBe(2);
});
it('marks hospice strains as museum gray without plague edges', () => {
const strain = '#aabbcc';
const graph = buildEpidemiologyGraph(
[
mkAgent({ id: 'a1', spread_strain: strain, join_lane: 'winrm' }),
mkAgent({
id: 'a2',
spread_strain: '#112233',
parent_agent_id: 'a1',
join_lane: 'smb',
spread_generation: 1,
}),
],
[strain],
);
const museum = graph.nodes.find((n) => n.id === strain);
expect(museum?.inHospice).toBe(true);
expect(museum?.color).toBe('#5a5a66');
expect(graph.edges.every((e) => e.weight === 0)).toBe(true);
});
it('weights edges by successful parent spreads only', () => {
const parentStrain = '#parent';
const childStrain = '#child';

View File

@@ -24,10 +24,13 @@ export interface MiningInterruptState {
parentAgentId?: string;
}
export const HOSPICE_MUSEUM_COLOR = '#5a5a66';
export interface StrainNode {
id: string;
label: string;
color: string;
inHospice?: boolean;
hostCount: number;
onlineCount: number;
spreadGeneration: number;
@@ -278,7 +281,16 @@ export function computeGoalPath(nodes: StrainNode[], edges: StrainEdge[]): strin
* 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 {
export function buildEpidemiologyGraph(
agents: Agent[],
hospiceStrains?: Set<string> | string[],
): EpidemiologyGraph {
const hospiceSet = new Set<string>();
if (hospiceStrains instanceof Set) {
for (const s of hospiceStrains) hospiceSet.add(s.trim().toLowerCase());
} else if (hospiceStrains) {
for (const s of hospiceStrains) hospiceSet.add(s.trim().toLowerCase());
}
const agentsById = new Map(agents.map((a) => [a.id, a]));
const strainBuckets = new Map<string, Agent[]>();
@@ -302,10 +314,12 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
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));
const inHospice = hospiceSet.has(strain);
nodes.push({
id: strain,
label: strainLabel(strain, joinLane, maxGen),
color: strain.startsWith('#') ? strain : `#${strain}`,
label: inHospice ? `${strainLabel(strain, joinLane, maxGen)} (museum)` : strainLabel(strain, joinLane, maxGen),
color: inHospice ? HOSPICE_MUSEUM_COLOR : strain.startsWith('#') ? strain : `#${strain}`,
inHospice,
hostCount: hosts.length,
onlineCount: online.length,
spreadGeneration: maxGen,
@@ -349,6 +363,9 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
};
edgeMap.set(key, edge);
}
if (hospiceSet.has(source) || hospiceSet.has(target)) {
continue;
}
if (isSuccessfulSpread(child)) edge.weight += 1;
else if (isFailedSpread(child)) edge.failedWeight += 1;
}
@@ -381,6 +398,7 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
}
export function nodeEmissiveIntensity(node: StrainNode): number {
if (node.inHospice) return 0.15;
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;
@@ -388,6 +406,7 @@ export function nodeEmissiveIntensity(node: StrainNode): number {
}
export function nodeWireOpacity(node: StrainNode): number {
if (node.inHospice) return 0.35;
if (node.branchStatus === 'failed') return 0.2;
if (node.miningContinuity === 'interrupted') return 0.35;
return 0.85;

View File

@@ -39,6 +39,18 @@ describe('pathTracerTimeline', () => {
expect(pick?.id).toBe('win');
});
it('skips hospice strains for fork-merge parent selection', () => {
const hospice = new Set(['#ab88e4']);
const pick = pickMergeCandidate(
[
branch({ id: 'hospice-win', status: 'won', spread_lanes: ['winrm'] }),
branch({ id: 'ok-run', status: 'running', spread_lanes: ['docker'] }),
],
hospice,
);
expect(pick?.id).toBe('ok-run');
});
it('maps status to CSS class', () => {
expect(branchStatusClass('won')).toBe('won');
expect(branchStatusClass('running')).toBe('running');

View File

@@ -2,6 +2,8 @@
* Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context.
*/
import { spreadStrainFromJoinLane } from './fleetTopologyEpidemiology';
export type TimelineBranchStatus =
| 'canonical'
| 'running'
@@ -89,8 +91,28 @@ export function ghostBranchesByHop(branches: PathTraceTimelineBranch[]): Map<num
return map;
}
export function pickMergeCandidate(branches: PathTraceTimelineBranch[]): PathTraceTimelineBranch | null {
const ghosts = branches.filter((b) => b.is_ghost);
function branchPrimaryLane(branch: PathTraceTimelineBranch): string {
return branch.spread_lanes?.[0]?.trim() ?? '';
}
/** Spread strain from join lane — mirrors Go SpreadStrainFromJoinLane for hospice guards. */
export function spreadStrainFromPersonaLane(lane: string): string {
if (!lane) return '';
return spreadStrainFromJoinLane(lane).toLowerCase();
}
export function pickMergeCandidate(
branches: PathTraceTimelineBranch[],
hospiceStrains?: Set<string>,
): PathTraceTimelineBranch | null {
const hospice = hospiceStrains ?? new Set<string>();
const eligible = (b: PathTraceTimelineBranch) => {
const lane = branchPrimaryLane(b);
if (!lane || hospice.size === 0) return true;
const strain = spreadStrainFromPersonaLane(lane);
return strain === '' || !hospice.has(strain);
};
const ghosts = branches.filter((b) => b.is_ghost && eligible(b));
const won = ghosts.find((b) => b.status === 'won' || b.mining_linked);
if (won) return won;
const running = ghosts.find((b) => b.status === 'running');

View File

@@ -11,6 +11,30 @@ export function isPathTraceTimelineEvent(event: SeerEventRecord): boolean {
return event.event_type === 'pathtrace_timeline';
}
export function isOnionMinerLogEvent(event: SeerEventRecord): boolean {
return event.event_type === 'onion_miner_log';
}
export function onionMinerLogSummary(event: SeerEventRecord): string {
if (!isOnionMinerLogEvent(event)) return '';
const p = event.payload ?? {};
const method = typeof p.method === 'string' ? p.method : 'branch';
const outcome = typeof p.outcome === 'string' ? p.outcome : 'update';
const depth = typeof p.contingency_depth === 'number' ? p.contingency_depth : undefined;
const ghost = p.is_ghost === true ? ' · ghost' : '';
const hr = typeof p.hashrate === 'number' && p.hashrate > 0 ? ` · ${Math.round(p.hashrate)} H/s` : '';
if (outcome === 'exhausted') {
return `Contingency exhausted${depth != null ? ` · depth ${depth}` : ''} — awaiting court branch params`;
}
if (outcome === 'strain_retired') {
return 'Contingency hospice — strain retired after max exhaustion cycles';
}
if (outcome === 'won' || outcome === 'ghost_won') {
return `Contingency ${method} linked${hr}${ghost}${depth != null ? ` · depth ${depth}` : ''}`;
}
return `Contingency ${method} ${outcome}${ghost}${depth != null ? ` · depth ${depth}` : ''}`;
}
export function pathTraceTimelineSummary(event: SeerEventRecord): string {
if (!isPathTraceTimelineEvent(event)) return '';
const p = event.payload ?? {};
@@ -28,6 +52,8 @@ export function isSurgicalReplayEvent(event: SeerEventRecord): boolean {
return event.event_type === 'surgical_replay';
}
export { isMiningSelfSurgeryEvent, miningSelfSurgerySummary } from './miningSelfSurgery';
export function surgicalReplaySummary(event: SeerEventRecord): string {
if (!isSurgicalReplayEvent(event)) {
return '';