PR3: Explicit global scan-budget priority scheduler (issue #2, phase 5)#9
Open
jdkajewski wants to merge 3 commits into
Open
PR3: Explicit global scan-budget priority scheduler (issue #2, phase 5)#9jdkajewski wants to merge 3 commits into
jdkajewski wants to merge 3 commits into
Conversation
…phase 5)
PR1 gave each market a per-market refresh interval; when many markets came
due in one sweep, refreshDue fetched every due market FIFO, reading dead
markets ahead of lane-critical ones and monopolising the shared ~2 req/s
budget that trade/nav also need. This makes the allocation explicit.
- market/scanBudget.ts (PURE): scanPriority = relValue x overrun (staleness),
scanBudgetPerSweep = floor(reqPerSec x sweepSeconds x fraction) capped, and
allocateScanBudget → {granted, deferred, byTier}. Deferral is not starvation:
a deferred market's overrun keeps rising until it wins a later sweep.
- markets.ts: when SCAN_BUDGET_ON, refreshDue ranks the due set by value x
staleness and grants only the per-sweep budget (rest ride the next sweep);
exposes scanBudgetStatus() for the metric. OFF ⇒ byte-for-byte legacy
fetch-all-due.
- main.ts: additive/conditional `scanBudget` block in the scan status snapshot
(perSweep, due, granted, deferred, byTier) so the prioritisation is provable.
- config.ts: SCAN_BUDGET_ON (boolOff), SCAN_BUDGET_REQ_PER_SEC (2),
SCAN_BUDGET_REQ_FRACTION (0.6), SCAN_BUDGET_MAX_PER_SWEEP (0). Default OFF.
Scoped to discretionary market-scan reads only; latency-sensitive action
requests keep hitting the client token bucket directly (no nav/trade
starvation). Calibration data unreachable from env → principled defaults + the
metric. 12 scanBudget unit tests + markets wiring tests; 279 bot tests green,
tsc + eslint clean (scoped files).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply the two orchestrator correctness steers to the global scan-budget scheduler (issue #2, phase 5): 1. Presence/coverage-gate the scan candidates. A `GET /market` only returns live prices where a ship is present, so spending budget on an uncovered market is a wasted request. The fleet poll (`fleetTableManager`, already always-on) now also snapshots every ship's present + inbound waypoint into `state.coverageWps` when `SCAN_BUDGET_ON` — no extra `getAllShips` call. `allocateDue` filters the DUE burst to that covered set before allocation and records the skipped count as a new `uncovered` scan-budget metric. The gate is skipped when coverage is empty (cold start, before the first poll) so behaviour matches today until coverage data exists. Cold re-check of uncovered markets stays PR2's separate presence-gated single read. 2. Headroom: `SCAN_BUDGET_MAX_PER_SWEEP` already caps the per-sweep burst to keep grants modest; documented the burst-vs-pacing tradeoff rather than injecting sleeps into the awaited sweep (would stall getMarkets callers). Pure `allocateScanBudget` stays presence-agnostic (gating is the caller's job, documented). +2 unit tests (covered-subset → only covered granted + uncovered counted; empty set → ungated). 281 bot tests green, tsc + eslint clean (pre-existing main.ts cast error unrelated). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fold in real calibration data parsed from 13.45h of the live production .mjs agent (UPRISING, phase PORTAL_OPEN, 226 ships) — zero API calls spent (log + status parse). Replaces "principled defaults" with empirical backing. Defaults: - SCAN_BUDGET_REQ_FRACTION 0.6 -> 0.4. Observed request mix was scans 40.1% / actions 55.7% / price 4.3%, so capping scans at their empirical ~40% share keeps actions' observed ~60% — matching Steer #2's action-protection intent ("leave ~0.6 for actions"). At the observed steady-state scan rate (5.74/min) the cap never binds; it only bounds a synchronized due-burst. (Note: the lever is the SCAN fraction; 0.6 would reserve only 40% for actions, below the observed 56% action load — hence 0.4.) - LANE_VALUE_* / LANE_TOPK / COVERAGE_COLD_MULT: values VALIDATED against the real distribution (net/lane median 21,980 p90 60,480, 15.5% negative; ~12 dominant sinks/goods; ~0.8 lanes/min) and annotated with the empirical basis. No value change — the data supports the existing picks. Replay calibration test (market/__tests__/replay.calibration.test.ts): feeds the REAL realized-net distribution (topSinksByNet fixture) through the pure cores (laneRegistry -> value.scoreMarkets -> scanScheduler.intervalFor -> scanBudget.allocateScanBudget) and proves the win on real numbers: - refresh cadence is MONOTONIC in realized value (no inversions across the real sinks; dead markets strictly slower); - concentration is >=10:1 hot:cold and CV > 0.3, vs the observed near-uniform 24.65 refreshes/market (hot read >5x the flat baseline); - a constrained 8-read budget is spent value-first (HOT-skewed, zero dead reads granted); - a dead tail sized to the real 15.5% negative-lane share parks at SCAN_MAX. 286 bot tests green (+5 replay), tsc + eslint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Issue #2 phase 5: make the shared ~2 req/s scan budget an explicit priority queue keyed by value × staleness, instead of FIFO.
PR1 gave each market a per-market refresh interval. But when many markets come due in one sweep,
refreshDuefetched every due market in scheduler (insertion) order with no cap — reading dead markets ahead of lane-critical ones and monopolising the same ~2 req/s budget that latency-sensitive trade/nav requests need. This PR spends a bounded per-sweep budget highest value×staleness first.Design (scan-scoped, lever-gated, safe default OFF)
market/scanBudget.ts(PURE, unit-tested)scanPriority(relValue, overrun) = max(0,relValue) × max(0,overrun)—relValue= value vs fleet mean (same scale as the PR1 scheduler),overrun = (now − lastScanAt)/interval(1.0 at due, rising the longer it waits). Never-scanned markets get a cold-start overrun so they are classified promptly (unknown ≠ dead).scanBudgetPerSweep = floor(reqPerSec × sweepSeconds × fraction), optional hard cap, floored at 1. The fraction reserves headroom so scans cannot starve trades.allocateScanBudget(candidates, budget)→{granted, deferred, byTier}: sort by priority desc (tie → longest-overdue first), grant topbudget, defer the rest.overrunkeeps climbing every sweep, so its priority rises until it is granted.markets.tswiring: whenSCAN_BUDGET_ON,refreshDueranks the due set and fetches only the granted waypoints; exposesscanBudgetStatus(). OFF ⇒ byte-for-byte legacy fetch-all-due.scanBudgetblock in thescanstatus snapshot (perSweep,due,granted,deferred,byTier) — the prioritisation + budget spend are now provable next to credits-per-request.Scope boundary (deliberate)
Prioritises/caps only discretionary market-scan reads. Latency-sensitive action requests (navigate/dock/sell/buy/contract) keep hitting the client token bucket directly — a fully general global queue would risk starving navigation / deadlocking trade loops. The budget is derived from a
SCAN_BUDGET_REQ_PER_SEClever (mirrors the client's bucket), so this PR does not modifyclients/spacetraders.ts.Levers (all
num()/boolOff, default OFF)SCAN_BUDGET_ONSCAN_BUDGET_REQ_PER_SECSCAN_BUDGET_REQ_FRACTIONSCAN_BUDGET_MAX_PER_SWEEPCalibration
Live legacy
.mjshistory is unreachable from this env → principled defaults + the metric (as agreed for PR1/PR2). The credits-per-request + newscanBudgetblock let us measure the effect once the TS bot runs live.Tests / verification
scanBudgetunit tests (priority ordering, budget derivation + headroom, hard cap, cold-start promotion, starvation-avoidance across sweeps, tier histogram, edge cases).markets.test.tswiring tests: null without the lever; due-burst capped to budget, rest deferred, status reported.@st/sharedbuild +@st/bot tsc --noEmitclean, eslint clean on all changed files. (One pre-existingmain.ts:63lint error exists on the base and is unrelated to this PR.)Out of scope
Phase 6 (traders-as-scanners) → later PR. Does not touch issue #8 (
putMarketsRecord-vs-array 400).Branched off latest
jdkajewski/ts-rewrite-plan(PR1 #5 + PR2 #6 already merged) — diff is PR3-only, no stacking.Update — correctness steers applied (commit 6e95e10)
Two reviewer steers, both landed:
1. Presence/coverage-gate the candidates. A
GETon an uncovered market is ship-presence-gated → returns no live prices → spending scan budget there is a wasted request.allocateScanBudgetnow only ever ranks markets that are scannable right now:fleetTableManager) now also snapshots every ship's present + inbound waypoint intostate.coverageWpswhenSCAN_BUDGET_ON— reusing the existinggetAllShips, no extra request. (PR2's exact "present or inbound" coverage definition.)markets.ts allocateDuefilters the DUE burst to that covered set before allocation, so budget never leaks onto a price-blind read. The pureallocateScanBudgetstays presence-agnostic (gating is the caller's job, documented in its JSDoc).uncoveredfield in thescanBudgetmetric makes the gate visible:due = granted + deferred + uncovered.2. Average vs instantaneous headroom.
SCAN_BUDGET_REQ_FRACTION < 1reserves average headroom, but a tight burst of granted scans could still momentarily monopolise the FIFO token bucket and delay an action arriving mid-burst. Tradeoff for v1:SCAN_BUDGET_MAX_PER_SWEEPis the burst cap — set it to keep granted-per-sweep modest so the bucket refills between scans. I did not inject inter-read pacing sleeps, because the sweep is awaited bygetMarketscallers and pacing there would stall the worker/contract loops; a true intra-sweep pacer + action-contention signal (needs token-bucket instrumentation) is deferred. Burst-cap alone is the v1 mechanism, as agreed.Verification: +2 presence-gating unit tests (covered-subset → only covered granted +
uncoveredcounted; empty set → ungated). 281 bot tests green,@st/sharedbuild +@st/bot tsc --noEmitclean, eslint clean on changed files (pre-existingmain.tscast error unrelated, now line 65 after a +2-line insertion).Update — calibrated from live production metrics (commit 1ecbf5d)
Folded in real calibration data parsed from 13.45h of the live UPRISING (.mjs) agent (phase PORTAL_OPEN, 226 ships, 29.15M cr) — gathered with zero API calls (log + status parse, production budget untouched). This replaces the "principled defaults" caveat with empirical backing.
Default changes / validation:
SCAN_BUDGET_REQ_FRACTION0.6 → 0.4. The observed request mix was scans 40.1% / actions 55.7% / price 4.3%. The lever is the scan share, so capping scans at their empirical ~40% keeps actions' observed ~60% — directly matching Steer Value-weighted, lane-driven market scan budgeting (replace uniform 1:1 probe coverage) #2's action-protection goal. At the observed steady-state scan rate (5.74/min) the cap never binds; it only bounds a synchronized due-burst. (0.6 would reserve only 40% for actions, below the observed 56% action load — hence 0.4.)LANE_VALUE_ALPHA/LANE_VALUE_HALFLIFE_MS/LANE_TOPK/COVERAGE_COLD_MULT: values validated, not changed — the real distribution (net/lane median 21,980, p90 60,480, 15.5% negative; ~12 dominant sinks/goods; ~0.8 lanes/min fleet-wide) supports the existing picks. Annotated each with its empirical basis inconfig.ts.Replay calibration test (
market/__tests__/replay.calibration.test.ts) — proves the win on real numbers, not toys. Feeds the real realized-net distribution (thetopSinksByNetfixture) through the pure cores (laneRegistry → value.scoreMarkets → scanScheduler.intervalFor → scanBudget.allocateScanBudget) and asserts:SCAN_MAXceiling.286 bot tests green (+5 replay),
@st/sharedbuild +@st/bot tscclean, eslint clean.