From a0a39f4dd7c9175d4e8090de3d80718a0203b413 Mon Sep 17 00:00:00 2001 From: Daniel Kajewski Date: Sat, 20 Jun 2026 10:03:23 -0500 Subject: [PATCH] =?UTF-8?q?feat(bot):=20FLEET=5FCOVERAGE=5FOBSERVE=20?= =?UTF-8?q?=E2=80=94=20pure-observe=20coverage=20baseline=20(issue=20#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged coverage controller had no zero-behavior-change observe path: turning on FLEET_COVERAGE_ADAPTIVE both emitted telemetry AND changed new-probe target + placement, so it couldn't serve as a clean baseline for measuring what the brain WOULD do before trusting it. Add an explicit observe-only lever so we can run TS live and watch the brain's decisions at zero risk — the data that justifies flipping mutation (and PR3's scheduler) on. - config: FLEET_COVERAGE_OBSERVE (boolOff, default OFF). - fleet/scale.ts: extract a PURE `coverageMode()` resolver. Precedence — OBSERVE forces a pure-observe baseline: the brain runs for TELEMETRY ONLY (legacy probe buys/placement, no redeploys) even if ADAPTIVE/PRUNE are set; flip OBSERVE off + ADAPTIVE/PRUNE on to ENACT. All three off ⇒ brain never runs ⇒ byte-for-byte legacy. - telemetry: `state.coverage` gains wouldPrune (DEAD prune candidates) and wouldRedeploy (the subset with a movable probe parked + a value destination — what enacting WOULD actually move) plus an `observe` flag, alongside the existing tiers/target/probesSaved/recheckDue. Surfaced via main.ts as before. - 8 coverageMode unit tests: all-off ⇒ null telemetry + legacy target + no enactment; OBSERVE ⇒ zero enactment + populated wouldPrune/wouldRedeploy; OBSERVE precedence over ADAPTIVE+PRUNE; ADAPTIVE/PRUNE enact paths; wouldRedeploy gating on parked-probe + value-dest. 273 bot tests green, @st/shared build + @st/bot tsc --noEmit clean, eslint clean on changed files. Branched off latest ts-rewrite-plan; own small PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bot/src/fleet/__tests__/fleet.test.ts | 81 ++++++++++- packages/bot/src/fleet/scale.ts | 134 +++++++++++++++--- packages/bot/src/runtime/state.ts | 6 + packages/shared/src/config.ts | 2 + 4 files changed, 199 insertions(+), 24 deletions(-) diff --git a/packages/bot/src/fleet/__tests__/fleet.test.ts b/packages/bot/src/fleet/__tests__/fleet.test.ts index 5417071..0ce4a89 100644 --- a/packages/bot/src/fleet/__tests__/fleet.test.ts +++ b/packages/bot/src/fleet/__tests__/fleet.test.ts @@ -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' }); @@ -24,6 +25,84 @@ describe('fleet scale helpers', () => { }); }); +describe('fleet coverage mode (issue #2 phases 4+7 + observe baseline)', () => { + const mkPlan = (over: Partial = {}): 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'; diff --git a/packages/bot/src/fleet/scale.ts b/packages/bot/src/fleet/scale.ts index 14da8e6..7de86f8 100644 --- a/packages/bot/src/fleet/scale.ts +++ b/packages/bot/src/fleet/scale.ts @@ -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'; @@ -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; + 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; + /** 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[] { return [...new Set(Object.values(yards).map((y) => y.wp).filter(Boolean))]; } @@ -204,31 +283,40 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { 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( @@ -255,7 +343,7 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { // 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) { diff --git a/packages/bot/src/runtime/state.ts b/packages/bot/src/runtime/state.ts index 9251b51..e23bddb 100644 --- a/packages/bot/src/runtime/state.ts +++ b/packages/bot/src/runtime/state.ts @@ -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; diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 878f2b1..4aa8861 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -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)