Skip to content
Open
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
81 changes: 80 additions & 1 deletion packages/bot/src/fleet/__tests__/fleet.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import { loadConfig } from '@st/shared';
import { makeShip } from '../../__tests__/fixtures.js';
import { cappedProbeTarget, isProbeHull, probeTargetFor } from '../scale.js';
import { cappedProbeTarget, coverageMode, isProbeHull, probeTargetFor } from '../scale.js';
import { repairTierDecision } from '../repair.js';
import type { CoveragePlan } from '../../market/coverage.js';

const cfg = loadConfig({ REPAIR_COND_MIN: '0.85', REPAIR_INTEG_FORCE: '0.5', REPAIR_MAX_COST: '100000' });

Expand All @@ -24,6 +25,84 @@ describe('fleet scale helpers', () => {
});
});

describe('fleet coverage mode (issue #2 phases 4+7 + observe baseline)', () => {
const mkPlan = (over: Partial<CoveragePlan> = {}): CoveragePlan => ({
tierByWp: new Map(),
shouldCover: ['A', 'B'],
toCover: ['A'],
toPrune: ['DEAD1'],
recheckDue: ['R1'],
counts: { HOT: 1, WARM: 1, COLD: 1, DEAD: 1 },
probesSaved: 3,
...over,
});
const base = {
plan: mkPlan(),
legacyProbeTarget: 7,
parkedProbeWps: new Set(['DEAD1']), // a movable probe sits on the DEAD prune candidate
hasValueDest: true,
coveredCount: 4,
now: 1000,
};

it('all three levers OFF ⇒ brain inert: null telemetry, legacy target, no enactment', () => {
const m = coverageMode({ ...base, plan: null, observe: false, adaptive: false, prune: false });
expect(m.telemetry).toBeNull();
expect(m.enactAdaptive).toBe(false);
expect(m.enactPrune).toBe(false);
expect(m.probeTarget).toBe(7); // legacy, untouched
});

it('OBSERVE on ⇒ telemetry only: zero enactment, legacy target, populated wouldPrune/wouldRedeploy', () => {
const m = coverageMode({ ...base, observe: true, adaptive: false, prune: false });
expect(m.enactAdaptive).toBe(false); // no value-driven placement
expect(m.enactPrune).toBe(false); // no redeploys
expect(m.probeTarget).toBe(7); // legacy buys/placement unchanged
expect(m.telemetry).not.toBeNull();
expect(m.telemetry!.observe).toBe(true);
expect(m.telemetry!.wouldPrune).toBe(1); // would-redeploy DEAD1
expect(m.telemetry!.wouldRedeploy).toBe(1); // probe parked there + value dest exists
});

it('OBSERVE forces observe even when ADAPTIVE+PRUNE are also set (clean baseline precedence)', () => {
const m = coverageMode({ ...base, observe: true, adaptive: true, prune: true });
expect(m.enactAdaptive).toBe(false);
expect(m.enactPrune).toBe(false);
expect(m.probeTarget).toBe(7); // still legacy — OBSERVE wins
expect(m.telemetry!.wouldRedeploy).toBe(1); // but still reports what it WOULD do
});

it('ADAPTIVE on (observe off) ⇒ enacts value target + placement, no prune', () => {
const m = coverageMode({ ...base, observe: false, adaptive: true, prune: false });
expect(m.enactAdaptive).toBe(true);
expect(m.enactPrune).toBe(false);
expect(m.probeTarget).toBe(2); // shouldCover.length, value-driven
});

it('ADAPTIVE+PRUNE on (observe off) ⇒ enacts redeploys', () => {
const m = coverageMode({ ...base, observe: false, adaptive: true, prune: true });
expect(m.enactAdaptive).toBe(true);
expect(m.enactPrune).toBe(true);
});

it('PRUNE without ADAPTIVE never enacts (plan would be null upstream)', () => {
const m = coverageMode({ ...base, plan: null, observe: false, adaptive: false, prune: true });
expect(m.enactPrune).toBe(false);
expect(m.telemetry).toBeNull();
});

it('wouldRedeploy is 0 when no probe is parked on a prune candidate', () => {
const m = coverageMode({ ...base, observe: true, adaptive: false, prune: false, parkedProbeWps: new Set() });
expect(m.telemetry!.wouldPrune).toBe(1); // still a candidate
expect(m.telemetry!.wouldRedeploy).toBe(0); // but nothing to actually move
});

it('wouldRedeploy is 0 when no value destination exists', () => {
const m = coverageMode({ ...base, observe: true, adaptive: false, prune: false, hasValueDest: false });
expect(m.telemetry!.wouldRedeploy).toBe(0);
});
});

describe('fleet repair helpers', () => {
it('decides opportunistic, forced-divert, healthy skip, and over-cost skip', () => {
const yard = 'X1-PP30-SY';
Expand Down
134 changes: 111 additions & 23 deletions packages/bot/src/fleet/scale.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ApiEnvelope } from '../interfaces.js';
import type { SubsystemDeps } from '../subsystems/deps.js';
import type { Ship } from '@st/shared';
import type { CoveragePlan } from '../market/coverage.js';
import { growthBudget } from '../budget/budget.js';
import { logger } from '../core/logger.js';

Expand Down Expand Up @@ -44,6 +45,84 @@ export function cappedProbeTarget(cargoShipCount: number, marketCount: number, b
return Math.min(probeCap, probeTargetFor(cargoShipCount, base, ratio));
}

/** Coverage telemetry surfaced in `state.coverage` (issue #2 phases 4+7, observe baseline). */
export interface CoverageTelemetry {
tierCounts: Record<string, number>;
target: number;
covered: number;
probesSaved: number;
recheckDue: number;
wouldPrune: number;
wouldRedeploy: number;
observe: boolean;
adaptive: boolean;
prune: boolean;
updatedAt: number;
}

export interface CoverageModeInput {
/** Brain output for this tick, or null when the brain didn't run (no OBSERVE/ADAPTIVE). */
plan: CoveragePlan | null;
observe: boolean;
adaptive: boolean;
prune: boolean;
/** Legacy 1:1 probe target used when the value-driven target isn't enacted. */
legacyProbeTarget: number;
/** Waypoints with a movable probe parked (excludes the negotiator + in-transit ships). */
parkedProbeWps: ReadonlySet<string>;
/** Whether a value-driven placement destination exists this tick (valuePlaceOrder non-empty). */
hasValueDest: boolean;
/** Markets currently covered (for the telemetry `covered` count). */
coveredCount: number;
now: number;
}

export interface CoverageMode {
/** Use the value-driven probe TARGET + PLACEMENT this tick (false in pure-observe / legacy). */
enactAdaptive: boolean;
/** Allowed to redeploy a probe off a DEAD market this tick (false in pure-observe / legacy). */
enactPrune: boolean;
/** Effective probe target: value-driven when enacting, else legacy. */
probeTarget: number;
/** Coverage telemetry for `state.coverage`; null when the brain didn't run. */
telemetry: CoverageTelemetry | null;
}

/**
* Resolve the coverage controller mode for one tick (issue #2 phases 4+7 + observe baseline).
*
* Precedence — **OBSERVE forces a pure-observe baseline**: when `observe` is set the brain runs for
* TELEMETRY ONLY (probe target/placement stay legacy, nothing is redeployed) even if `adaptive`/`prune`
* are also set. Flip OBSERVE off and ADAPTIVE/PRUNE on to ENACT. With all three off the brain never
* runs (`plan === null`) and this returns the legacy target with no telemetry — byte-for-byte legacy.
*
* `wouldPrune`/`wouldRedeploy` are dry-run signals computed whenever the brain ran (observe OR adaptive):
* the DEAD prune candidates, and the subset that has a movable probe parked AND a value destination —
* i.e. what enacting WOULD actually move — so the observe pass shows the redeploys it would make.
*/
export function coverageMode(input: CoverageModeInput): CoverageMode {
const { plan, observe, adaptive, prune, legacyProbeTarget, parkedProbeWps, hasValueDest, coveredCount, now } = input;
const enactAdaptive = !!plan && adaptive && !observe;
const enactPrune = !!plan && adaptive && prune && !observe;
const probeTarget = enactAdaptive && plan ? plan.shouldCover.length : legacyProbeTarget;
if (!plan) return { enactAdaptive, enactPrune, probeTarget, telemetry: null };
const redeployable = plan.toPrune.filter((wp) => parkedProbeWps.has(wp)).length;
const telemetry: CoverageTelemetry = {
tierCounts: plan.counts,
target: plan.shouldCover.length,
covered: coveredCount,
probesSaved: plan.probesSaved,
recheckDue: plan.recheckDue.length,
wouldPrune: plan.toPrune.length,
wouldRedeploy: hasValueDest ? redeployable : 0,
observe,
adaptive,
prune,
updatedAt: now,
};
return { enactAdaptive, enactPrune, probeTarget, telemetry };
}

export function shipyardWps(yards: Record<string, ShipyardOffer>): string[] {
return [...new Set(Object.values(yards).map((y) => y.wp).filter(Boolean))];
}
Expand Down Expand Up @@ -204,31 +283,40 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
const haulers = all.filter((s) => !isProbeHull(s) && (s.cargo?.capacity ?? 0) >= 80).length;
const legacyProbeTarget = cappedProbeTarget(cargoShips, marketWps.length, cfg.FLEET_BASE_PROBES, cfg.FLEET_PROBE_RATIO, cfg.FLEET_MAX_PROBES);

// [issue #2 phases 4+7] Value-driven coverage controller (opt-in). When FLEET_COVERAGE_ADAPTIVE
// is on, probe TARGET + PLACEMENT follow live per-market value instead of ~1 probe per market.
// FLEET_COVERAGE_PRUNE additionally redeploys probes off markets we've read and found DEAD. Both
// default OFF → this whole block is inert and behaviour is byte-for-byte the legacy path.
const plan = cfg.FLEET_COVERAGE_ADAPTIVE
// [issue #2 phases 4+7 + observe baseline] Value-driven coverage controller (opt-in). The brain
// runs when OBSERVE or ADAPTIVE is set. OBSERVE forces a pure-observe baseline (telemetry only,
// legacy buys/placement, no redeploys) even if ADAPTIVE/PRUNE are set; flip OBSERVE off +
// ADAPTIVE/PRUNE on to enact. All three off → brain inert → byte-for-byte legacy.
const runBrain = cfg.FLEET_COVERAGE_OBSERVE || cfg.FLEET_COVERAGE_ADAPTIVE;
const plan = runBrain
? deps.markets.coveragePlan(covered, { fleetSize: all.length, marketCount: marketWps.length })
: null;
if (plan) {
state.coverage = {
tierCounts: plan.counts,
target: plan.shouldCover.length,
covered: marketWps.length - uncovered.length,
probesSaved: plan.probesSaved,
recheckDue: plan.recheckDue.length,
adaptive: cfg.FLEET_COVERAGE_ADAPTIVE,
prune: cfg.FLEET_COVERAGE_PRUNE,
updatedAt: Date.now(),
};
}
// Probe target: count of markets actually worth covering (value-driven), else the legacy 1:1 cap.
const probeTarget = plan ? plan.shouldCover.length : legacyProbeTarget;
// Placement order: highest-value uncovered first (value-driven), else legacy export-preferring + nearest.
const valuePlaceOrder = plan
? [...plan.toCover, ...plan.recheckDue].filter((w, i, a) => a.indexOf(w) === i && !covered.has(w))
: [];
const parkedProbeWps = new Set(
all
.filter((s) => isProbeHull(s) && s.symbol !== cfg.NEGOTIATOR && s.nav.status !== 'IN_TRANSIT')
.map((s) => s.nav.waypointSymbol),
);
const mode = coverageMode({
plan,
observe: cfg.FLEET_COVERAGE_OBSERVE,
adaptive: cfg.FLEET_COVERAGE_ADAPTIVE,
prune: cfg.FLEET_COVERAGE_PRUNE,
legacyProbeTarget,
parkedProbeWps,
hasValueDest: valuePlaceOrder.length > 0,
coveredCount: marketWps.length - uncovered.length,
now: Date.now(),
});
if (mode.telemetry) state.coverage = mode.telemetry;
// Probe target: value-driven count when enacting, else the legacy 1:1 cap.
const probeTarget = mode.probeTarget;
// Placement order: highest-value uncovered first only when enacting adaptive; else legacy export-preferring + nearest.
const placeOrder = (): string[] =>
plan
? [...plan.toCover, ...plan.recheckDue].filter((w, i, a) => a.indexOf(w) === i && !covered.has(w))
mode.enactAdaptive && plan
? valuePlaceOrder.filter((w) => !covered.has(w))
: uncovered
.slice()
.sort(
Expand All @@ -255,7 +343,7 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
// highest-value market needing coverage. The vacated market isn't forgotten — it re-enters the cold
// re-check schedule (recheckDue) and is promoted back if its lanes ever light up. FLEET_COVERAGE_PRUNE
// gated; only fires when there's somewhere strictly better to put the probe (no churn otherwise).
if (plan && cfg.FLEET_COVERAGE_PRUNE && plan.toPrune.length) {
if (plan && mode.enactPrune && plan.toPrune.length) {
const dest = placeOrder()[0];
const pruneWp = plan.toPrune[0];
if (dest && pruneWp) {
Expand Down
6 changes: 6 additions & 0 deletions packages/bot/src/runtime/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ export interface BotState {
covered: number;
probesSaved: number;
recheckDue: number;
/** Markets the brain WOULD redeploy a probe off (DEAD prune candidates) — dry-run signal. */
wouldPrune: number;
/** Of those, how many have a movable probe parked + a value destination → would actually move. */
wouldRedeploy: number;
/** Pure-observe baseline active (telemetry only, no fleet mutations). */
observe: boolean;
adaptive: boolean;
prune: boolean;
updatedAt: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ const RawConfigSchema = z.object({
// switches default OFF, so with defaults `fleet/scale` is byte-for-byte the legacy behaviour.
FLEET_COVERAGE_ADAPTIVE: boolOff, // value-driven probe TARGET + PLACEMENT (non-mutating to existing probes)
FLEET_COVERAGE_PRUNE: boolOff, // also redeploy probes off DEAD markets + cold re-visit (FLEET-MUTATING)
FLEET_COVERAGE_OBSERVE: boolOff, // pure-observe baseline: run the brain for TELEMETRY ONLY (wouldPrune/wouldRedeploy),
// legacy probe buys/placement unchanged, no redeploys — even if ADAPTIVE/PRUNE are set (OBSERVE forces observe).
COVERAGE_HOT_MULT: num(2), // rel value ≥ 2× fleet mean ⇒ HOT
COVERAGE_WARM_MULT: num(0.75), // rel ≥ 0.75× ⇒ WARM
COVERAGE_COLD_MULT: num(0.2), // rel ≥ 0.2× ⇒ COLD; below ⇒ DEAD (never worth a parked probe)
Expand Down