Add Strain Hospice for graceful low-win strain retirement.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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:
@@ -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`}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user