Spaces:
Running
Running
Commit ·
d2258e5
1
Parent(s): 94910ac
Add online predictive KV tiering experiments
Browse files- README.md +14 -2
- app.js +118 -4
- docs/methodology.md +42 -0
- docs/research.md +24 -0
- index.html +49 -3
- py/inferscale/__init__.py +5 -1
- py/inferscale/agentic.py +284 -8
- py/inferscale/api.py +19 -0
- py/inferscale/prediction.py +72 -0
- pyproject.toml +1 -1
- scripts/release_check.py +29 -3
- src/inferscale/__init__.py +5 -1
- src/inferscale/agentic.py +284 -8
- src/inferscale/api.py +19 -0
- src/inferscale/prediction.py +72 -0
- styles.css +5 -1
- tests/test_prediction.py +73 -0
- worker.mjs +1 -0
README.md
CHANGED
|
@@ -72,6 +72,7 @@ arrival_time,prompt_tokens,output_tokens
|
|
| 72 |
- P/D transfer latency / transfer volume / role utilization
|
| 73 |
- heuristic bottleneck diagnoses with explicit simulator provenance
|
| 74 |
- cross-turn KV hit rate, recomputed history tokens, routing locality, HBM/host GB-seconds, TTL/pressure evictions, and explicit host-tier transfer latency for stateful sessions
|
|
|
|
| 75 |
|
| 76 |
## Interactive experiments
|
| 77 |
|
|
@@ -109,7 +110,7 @@ Run a bounded live sweep across scheduler, batch size, prefix caching, and P/D w
|
|
| 109 |
|
| 110 |
Model stateful multi-turn programs rather than isolated requests. Each program has ordered LLM turns separated by tool-call gaps. The simulator tracks cross-turn KV residency on multiple replicas, routing locality, TTL expiry, pressure eviction, recomputation after cache loss, and memory-time residency.
|
| 111 |
|
| 112 |
-
|
| 113 |
|
| 114 |
1. **Session run:** inspect one retention/routing policy on a generated program trace.
|
| 115 |
2. **Session Policy Arena:** replay the exact same program trace through simple stateless/retention baselines.
|
|
@@ -117,9 +118,20 @@ Six live experiments are included:
|
|
| 117 |
4. **Agent Memory Lab:** compare HBM retention, bounded affinity, host-memory offload, and an explicitly labeled oracle gap-aware tiering upper bound.
|
| 118 |
5. **Finite-HBM budget stress:** derive per-replica KV budgets from the trace's unconstrained working set and rerun tiering policies under deliberate memory pressure.
|
| 119 |
6. **Affinity Frontier:** sweep the queue-delay slack that bounded-affinity routing is willing to pay for cache locality.
|
|
|
|
|
|
|
| 120 |
|
| 121 |
Agent mode intentionally uses a serial service station per replica. The existing Serving Lab models batching; Agent Sessions isolates program dependencies, cache locality, tool gaps, and state-management decisions so those effects are interpretable rather than conflated.
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
### Research Studies
|
| 124 |
|
| 125 |
This is the statistical/research layer rather than another configuration dashboard.
|
|
@@ -169,7 +181,7 @@ This makes profile uncertainty visible rather than allowing one uncalibrated pro
|
|
| 169 |
| | | |
|
| 170 |
| +-----------------------+------------------+ |
|
| 171 |
| v |
|
| 172 |
-
| metrics / search / research / agent sessions |
|
| 173 |
+------------------------------------------------------------------+
|
| 174 |
```
|
| 175 |
|
|
|
|
| 72 |
- P/D transfer latency / transfer volume / role utilization
|
| 73 |
- heuristic bottleneck diagnoses with explicit simulator provenance
|
| 74 |
- cross-turn KV hit rate, recomputed history tokens, routing locality, HBM/host GB-seconds, TTL/pressure evictions, and explicit host-tier transfer latency for stateful sessions
|
| 75 |
+
- online tool-gap prediction MAE, oracle-action agreement, and pre/post-shift adaptation diagnostics
|
| 76 |
|
| 77 |
## Interactive experiments
|
| 78 |
|
|
|
|
| 110 |
|
| 111 |
Model stateful multi-turn programs rather than isolated requests. Each program has ordered LLM turns separated by tool-call gaps. The simulator tracks cross-turn KV residency on multiple replicas, routing locality, TTL expiry, pressure eviction, recomputation after cache loss, and memory-time residency.
|
| 112 |
|
| 113 |
+
Eight live experiments are included:
|
| 114 |
|
| 115 |
1. **Session run:** inspect one retention/routing policy on a generated program trace.
|
| 116 |
2. **Session Policy Arena:** replay the exact same program trace through simple stateless/retention baselines.
|
|
|
|
| 118 |
4. **Agent Memory Lab:** compare HBM retention, bounded affinity, host-memory offload, and an explicitly labeled oracle gap-aware tiering upper bound.
|
| 119 |
5. **Finite-HBM budget stress:** derive per-replica KV budgets from the trace's unconstrained working set and rerun tiering policies under deliberate memory pressure.
|
| 120 |
6. **Affinity Frontier:** sweep the queue-delay slack that bounded-affinity routing is willing to pay for cache locality.
|
| 121 |
+
7. **Predictive Tiering Lab:** replace the clairvoyant gap-aware policy with online global/per-tool EWMA predictors that learn only from completed tool calls, then compare them on a non-stationary program trace.
|
| 122 |
+
8. **Adaptation-rate sweep:** vary the EWMA update rate on the exact same shifted trace to expose the stability-versus-reactivity trade-off.
|
| 123 |
|
| 124 |
Agent mode intentionally uses a serial service station per replica. The existing Serving Lab models batching; Agent Sessions isolates program dependencies, cache locality, tool gaps, and state-management decisions so those effects are interpretable rather than conflated.
|
| 125 |
|
| 126 |
+
### Predictive tiering without future leakage
|
| 127 |
+
|
| 128 |
+
The adaptive KV policy does **not** read the sampled future tool duration when it decides whether to retain state in HBM or offload it. Retained adaptive state is also bounded by the configured KV safety TTL, so a bad short-gap prediction cannot pin HBM indefinitely. Each completed tool call emits a later observation event; only then is its duration added to the online predictor. Two transparent predictors are available:
|
| 129 |
+
|
| 130 |
+
- a global EWMA over all completed tools;
|
| 131 |
+
- a per-tool EWMA that falls back to the global estimate during warm-up.
|
| 132 |
+
|
| 133 |
+
The Predictive Tiering Lab deliberately changes the duration distribution of slower external tools partway through one shared trace. It compares fixed TTL, unconditional host offload, global prediction, per-tool prediction, and an explicitly labeled oracle upper bound. Prediction error and serving outcomes are shown together so a lower forecasting error is not automatically treated as a better systems policy.
|
| 134 |
+
|
| 135 |
### Research Studies
|
| 136 |
|
| 137 |
This is the statistical/research layer rather than another configuration dashboard.
|
|
|
|
| 181 |
| | | |
|
| 182 |
| +-----------------------+------------------+ |
|
| 183 |
| v |
|
| 184 |
+
| metrics / search / research / agent sessions / online prediction |
|
| 185 |
+------------------------------------------------------------------+
|
| 186 |
```
|
| 187 |
|
app.js
CHANGED
|
@@ -16,6 +16,8 @@ let lastAgentTtl = null;
|
|
| 16 |
let lastAgentMemory = null;
|
| 17 |
let lastAgentBudget = null;
|
| 18 |
let lastAgentAffinity = null;
|
|
|
|
|
|
|
| 19 |
let traceRequests = [];
|
| 20 |
|
| 21 |
const COLORS = {
|
|
@@ -39,7 +41,7 @@ worker.addEventListener("message", (event) => {
|
|
| 39 |
if (data.type === "ready") {
|
| 40 |
runtimePill.classList.add("ready");
|
| 41 |
runtimeText.textContent = "Python runtime ready";
|
| 42 |
-
["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn", "agentRunBtn", "agentCompareBtn", "agentTtlBtn", "agentMemoryCompareBtn", "agentBudgetBtn", "agentAffinityBtn"].forEach((id) => { $(id).disabled = false; });
|
| 43 |
syncConditionalControls();
|
| 44 |
window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
|
| 45 |
return;
|
|
@@ -780,6 +782,9 @@ function agentConfigFromUI(overrides = {}) {
|
|
| 780 |
host_memory_gb: num("agentHostMemory"),
|
| 781 |
host_bandwidth_gbps: num("agentHostBandwidth"),
|
| 782 |
host_transfer_base_ms: num("agentHostBase"),
|
|
|
|
|
|
|
|
|
|
| 783 |
slo_turn_ttft_ms: num("agentTtftSlo"),
|
| 784 |
slo_session_e2e_ms: num("agentSessionSlo"),
|
| 785 |
...overrides,
|
|
@@ -984,12 +989,119 @@ $("agentAffinityBtn").addEventListener("click", async () => {
|
|
| 984 |
$("agentAffinityCopy").addEventListener("click", () => { if (lastAgentAffinity) copyText(tableText(agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)), "Affinity sweep copied"); });
|
| 985 |
$("agentAffinityCsv").addEventListener("click", () => { if (lastAgentAffinity) downloadCsv(`inferscale_agent-affinity-frontier_${stamp()}.csv`, agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)); });
|
| 986 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
function syncAgentControls() {
|
| 988 |
const retention = $("agentRetention").value;
|
| 989 |
const routing = $("agentRouting").value;
|
| 990 |
-
const ttl = retention === "ttl";
|
| 991 |
-
const
|
| 992 |
-
const
|
|
|
|
| 993 |
const bounded = routing === "bounded_affinity";
|
| 994 |
$("agentTtl").disabled = !ttl;
|
| 995 |
$("agentTtlLabel").classList.toggle("field-disabled", !ttl);
|
|
@@ -999,6 +1111,8 @@ function syncAgentControls() {
|
|
| 999 |
$("agentGapThresholdLabel").classList.toggle("field-disabled", !gapAware);
|
| 1000 |
["agentHostMemory", "agentHostBandwidth", "agentHostBase"].forEach((id) => { $(id).disabled = !hostTier; });
|
| 1001 |
["agentHostMemoryLabel", "agentHostBandwidthLabel", "agentHostBaseLabel"].forEach((id) => { $(id).classList.toggle("field-disabled", !hostTier); });
|
|
|
|
|
|
|
| 1002 |
}
|
| 1003 |
$("agentRetention").addEventListener("change", syncAgentControls);
|
| 1004 |
$("agentRouting").addEventListener("change", syncAgentControls);
|
|
|
|
| 16 |
let lastAgentMemory = null;
|
| 17 |
let lastAgentBudget = null;
|
| 18 |
let lastAgentAffinity = null;
|
| 19 |
+
let lastPredictiveStudy = null;
|
| 20 |
+
let lastPredictiveAlpha = null;
|
| 21 |
let traceRequests = [];
|
| 22 |
|
| 23 |
const COLORS = {
|
|
|
|
| 41 |
if (data.type === "ready") {
|
| 42 |
runtimePill.classList.add("ready");
|
| 43 |
runtimeText.textContent = "Python runtime ready";
|
| 44 |
+
["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn", "agentRunBtn", "agentCompareBtn", "agentTtlBtn", "agentMemoryCompareBtn", "agentBudgetBtn", "agentAffinityBtn", "predictiveCompareBtn", "predictiveAlphaBtn"].forEach((id) => { $(id).disabled = false; });
|
| 45 |
syncConditionalControls();
|
| 46 |
window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
|
| 47 |
return;
|
|
|
|
| 782 |
host_memory_gb: num("agentHostMemory"),
|
| 783 |
host_bandwidth_gbps: num("agentHostBandwidth"),
|
| 784 |
host_transfer_base_ms: num("agentHostBase"),
|
| 785 |
+
adaptive_predictor_scope: $("agentPredictorScope").value,
|
| 786 |
+
adaptive_alpha: num("agentPredictorAlpha"),
|
| 787 |
+
adaptive_min_observations: num("agentPredictorMin"),
|
| 788 |
slo_turn_ttft_ms: num("agentTtftSlo"),
|
| 789 |
slo_session_e2e_ms: num("agentSessionSlo"),
|
| 790 |
...overrides,
|
|
|
|
| 989 |
$("agentAffinityCopy").addEventListener("click", () => { if (lastAgentAffinity) copyText(tableText(agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)), "Affinity sweep copied"); });
|
| 990 |
$("agentAffinityCsv").addEventListener("click", () => { if (lastAgentAffinity) downloadCsv(`inferscale_agent-affinity-frontier_${stamp()}.csv`, agentAffinityHeaders, agentAffinityTableRows(lastAgentAffinity)); });
|
| 991 |
|
| 992 |
+
|
| 993 |
+
const predictiveHeaders = ["Policy", "p95 turn TTFT", "p95 session E2E", "Session SLO", "Reuse", "Mean HBM", "Mean host", "Prediction MAE", "Oracle agreement", "Post-shift MAE", "Recomputed"];
|
| 994 |
+
function predictiveTableRows(result) {
|
| 995 |
+
return result.rows.map((r) => [
|
| 996 |
+
r.label,
|
| 997 |
+
`${fmt(r.p95_turn_ttft_ms)} ms`,
|
| 998 |
+
`${fmt(r.p95_session_e2e_ms)} ms`,
|
| 999 |
+
pct(r.session_slo_attainment),
|
| 1000 |
+
pct(r.cache_hit_rate),
|
| 1001 |
+
`${fmt(r.mean_hbm_gb, 4)} GB`,
|
| 1002 |
+
`${fmt(r.mean_host_gb, 4)} GB`,
|
| 1003 |
+
r.prediction_count ? `${fmt(r.prediction_mae_s, 3)} s` : "N/A",
|
| 1004 |
+
r.prediction_count ? pct(r.oracle_action_agreement) : "N/A",
|
| 1005 |
+
r.prediction_count ? `${fmt(r.post_shift_mae_s, 3)} s` : "N/A",
|
| 1006 |
+
`${fmt(r.recomputed_history_tokens, 0)} tok`,
|
| 1007 |
+
]);
|
| 1008 |
+
}
|
| 1009 |
+
function renderPredictiveStudy(result) {
|
| 1010 |
+
lastPredictiveStudy = result;
|
| 1011 |
+
$("predictiveEmpty").classList.add("hidden");
|
| 1012 |
+
$("predictiveContent").classList.remove("hidden");
|
| 1013 |
+
$("predictiveCompareBlock").classList.remove("hidden");
|
| 1014 |
+
["predictiveCopy", "predictiveCsv"].forEach((id) => { $(id).disabled = false; });
|
| 1015 |
+
const nonOracle = result.rows.filter((r) => r.retention !== "gap_aware");
|
| 1016 |
+
const best = [...nonOracle].sort((a, b) => a.p95_turn_ttft_ms - b.p95_turn_ttft_ms)[0];
|
| 1017 |
+
const tool = result.rows.find((r) => r.label === "Adaptive per-tool EWMA");
|
| 1018 |
+
$("predictiveBestTtft").textContent = best ? `${fmt(best.p95_turn_ttft_ms)} ms` : "N/A";
|
| 1019 |
+
$("predictiveAgreement").textContent = tool ? pct(tool.oracle_action_agreement) : "N/A";
|
| 1020 |
+
$("predictivePostMae").textContent = tool ? `${fmt(tool.post_shift_mae_s, 3)} s` : "N/A";
|
| 1021 |
+
$("predictiveShiftValue").textContent = `${fmt(result.shift_fraction * 100, 0)}% / ${fmt(result.shift_multiplier, 2)}x`;
|
| 1022 |
+
$("predictiveRows").innerHTML = result.rows.map((r) => `<tr><td>${escapeHtml(r.label)}</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${fmt(r.p95_session_e2e_ms)} ms</td><td>${pct(r.session_slo_attainment)}</td><td>${pct(r.cache_hit_rate)}</td><td>${fmt(r.mean_hbm_gb, 4)} GB</td><td>${fmt(r.mean_host_gb, 4)} GB</td><td>${r.prediction_count ? `${fmt(r.prediction_mae_s, 3)} s` : "N/A"}</td><td>${r.prediction_count ? pct(r.oracle_action_agreement) : "N/A"}</td><td>${r.prediction_count ? `${fmt(r.post_shift_mae_s, 3)} s` : "N/A"}</td><td>${fmt(r.recomputed_history_tokens, 0)} tok</td></tr>`).join("");
|
| 1023 |
+
|
| 1024 |
+
const palette = [COLORS.amber, COLORS.steel, COLORS.purple, COLORS.blue, COLORS.green];
|
| 1025 |
+
destroyChart("predictivePolicy");
|
| 1026 |
+
charts.predictivePolicy = new Chart($("predictivePolicyChart"), {
|
| 1027 |
+
type: "scatter",
|
| 1028 |
+
data: { datasets: result.rows.map((r, i) => ({ label: r.label, data: [{ x: r.mean_hbm_gb, y: r.p95_turn_ttft_ms }], backgroundColor: palette[i], borderColor: palette[i], pointRadius: 7, pointHoverRadius: 9 })) },
|
| 1029 |
+
options: { ...commonChartOptions(), parsing: false, scales: { x: { type: "linear", beginAtZero: true, title: { display: true, text: "Mean HBM KV (GB)" } }, y: { beginAtZero: true, title: { display: true, text: "p95 turn TTFT (ms)" } } } },
|
| 1030 |
+
});
|
| 1031 |
+
|
| 1032 |
+
const globalCurve = result.learning_curves.global || [];
|
| 1033 |
+
const toolCurve = result.learning_curves.per_tool || [];
|
| 1034 |
+
const maxMae = Math.max(0.1, ...globalCurve.map((r) => r.rolling_mae_s), ...toolCurve.map((r) => r.rolling_mae_s));
|
| 1035 |
+
const shiftX = result.shift_observation || 0;
|
| 1036 |
+
const learningDatasets = [
|
| 1037 |
+
{ label: "Global EWMA rolling MAE", data: globalCurve.map((r) => ({ x: r.observation, y: r.rolling_mae_s })), borderColor: COLORS.steel, backgroundColor: COLORS.steel, pointRadius: 0, tension: .16, fill: false },
|
| 1038 |
+
{ label: "Per-tool EWMA rolling MAE", data: toolCurve.map((r) => ({ x: r.observation, y: r.rolling_mae_s })), borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointRadius: 0, tension: .16, fill: false },
|
| 1039 |
+
];
|
| 1040 |
+
if (shiftX > 0) learningDatasets.push({ label: "Regime shift", data: [{ x: shiftX, y: 0 }, { x: shiftX, y: maxMae }], borderColor: COLORS.amber, backgroundColor: COLORS.amber, pointRadius: 0, borderDash: [6, 5], fill: false });
|
| 1041 |
+
destroyChart("predictiveLearning");
|
| 1042 |
+
charts.predictiveLearning = new Chart($("predictiveLearningChart"), {
|
| 1043 |
+
type: "line",
|
| 1044 |
+
data: { datasets: learningDatasets },
|
| 1045 |
+
options: { ...commonChartOptions(), parsing: false, plugins: { legend: lineLegend() }, scales: { x: { type: "linear", beginAtZero: true, title: { display: true, text: "Completed tool observations" } }, y: { beginAtZero: true, title: { display: true, text: "Rolling absolute error (s)" } } } },
|
| 1046 |
+
});
|
| 1047 |
+
}
|
| 1048 |
+
function predictivePayload() {
|
| 1049 |
+
return {
|
| 1050 |
+
config: agentConfigFromUI({ routing_policy: "bounded_affinity" }),
|
| 1051 |
+
horizon_s: num("predictiveHorizon"),
|
| 1052 |
+
shift_fraction: num("predictiveShift"),
|
| 1053 |
+
shift_multiplier: num("predictiveMultiplier"),
|
| 1054 |
+
alpha: num("predictiveAlpha"),
|
| 1055 |
+
};
|
| 1056 |
+
}
|
| 1057 |
+
$("predictiveCompareBtn").addEventListener("click", async () => {
|
| 1058 |
+
const button = $("predictiveCompareBtn"); button.disabled = true; button.textContent = "Running predictive study...";
|
| 1059 |
+
try { renderPredictiveStudy(await callPython("agent_predictive_tiering", predictivePayload())); }
|
| 1060 |
+
catch (error) { alert(`Predictive tiering study failed: ${error.message}`); }
|
| 1061 |
+
finally { button.disabled = false; button.textContent = "Compare predictive policies"; }
|
| 1062 |
+
});
|
| 1063 |
+
$("predictiveCopy").addEventListener("click", () => { if (lastPredictiveStudy) copyText(tableText(predictiveHeaders, predictiveTableRows(lastPredictiveStudy)), "Predictive-tiering table copied"); });
|
| 1064 |
+
$("predictiveCsv").addEventListener("click", () => { if (lastPredictiveStudy) downloadCsv(`inferscale_predictive-tiering_${stamp()}.csv`, predictiveHeaders, predictiveTableRows(lastPredictiveStudy)); });
|
| 1065 |
+
|
| 1066 |
+
const predictiveAlphaHeaders = ["Alpha", "Pre-shift MAE", "Post-shift MAE", "Oracle agreement", "p95 TTFT", "p95 session E2E", "Session SLO", "Mean HBM", "Mean host"];
|
| 1067 |
+
function predictiveAlphaTableRows(result) {
|
| 1068 |
+
return result.rows.map((r) => [fmt(r.alpha, 2), `${fmt(r.pre_shift_mae_s, 3)} s`, `${fmt(r.post_shift_mae_s, 3)} s`, pct(r.oracle_action_agreement), `${fmt(r.p95_turn_ttft_ms)} ms`, `${fmt(r.p95_session_e2e_ms)} ms`, pct(r.session_slo_attainment), `${fmt(r.mean_hbm_gb, 4)} GB`, `${fmt(r.mean_host_gb, 4)} GB`]);
|
| 1069 |
+
}
|
| 1070 |
+
function renderPredictiveAlpha(result) {
|
| 1071 |
+
lastPredictiveAlpha = result;
|
| 1072 |
+
$("predictiveEmpty").classList.add("hidden");
|
| 1073 |
+
$("predictiveContent").classList.remove("hidden");
|
| 1074 |
+
$("predictiveAlphaBlock").classList.remove("hidden");
|
| 1075 |
+
["predictiveAlphaCopy", "predictiveAlphaCsv"].forEach((id) => { $(id).disabled = false; });
|
| 1076 |
+
$("predictiveAlphaCaption").textContent = `Adaptation-rate sweep (best post-shift alpha ${fmt(result.best_post_shift_alpha, 2)})`;
|
| 1077 |
+
$("predictiveAlphaRows").innerHTML = result.rows.map((r) => `<tr><td>${fmt(r.alpha, 2)}</td><td>${fmt(r.pre_shift_mae_s, 3)} s</td><td>${fmt(r.post_shift_mae_s, 3)} s</td><td>${pct(r.oracle_action_agreement)}</td><td>${fmt(r.p95_turn_ttft_ms)} ms</td><td>${fmt(r.p95_session_e2e_ms)} ms</td><td>${pct(r.session_slo_attainment)}</td><td>${fmt(r.mean_hbm_gb, 4)} GB</td><td>${fmt(r.mean_host_gb, 4)} GB</td></tr>`).join("");
|
| 1078 |
+
destroyChart("predictiveAlpha");
|
| 1079 |
+
charts.predictiveAlpha = new Chart($("predictiveAlphaChart"), {
|
| 1080 |
+
type: "line",
|
| 1081 |
+
data: { datasets: [
|
| 1082 |
+
{ label: "Pre-shift MAE", data: result.rows.map((r) => ({ x: r.alpha, y: r.pre_shift_mae_s })), borderColor: COLORS.steel, backgroundColor: COLORS.steel, pointRadius: 4, tension: .12, fill: false },
|
| 1083 |
+
{ label: "Post-shift MAE", data: result.rows.map((r) => ({ x: r.alpha, y: r.post_shift_mae_s })), borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointRadius: 4, tension: .12, fill: false },
|
| 1084 |
+
] },
|
| 1085 |
+
options: { ...commonChartOptions(), parsing: false, plugins: { legend: lineLegend() }, scales: { x: { type: "linear", min: 0, max: 1, title: { display: true, text: "EWMA alpha" } }, y: { beginAtZero: true, title: { display: true, text: "Mean absolute error (s)" } } } },
|
| 1086 |
+
});
|
| 1087 |
+
}
|
| 1088 |
+
$("predictiveAlphaBtn").addEventListener("click", async () => {
|
| 1089 |
+
const button = $("predictiveAlphaBtn"); button.disabled = true; button.textContent = "Sweeping adaptation rate...";
|
| 1090 |
+
const payload = predictivePayload();
|
| 1091 |
+
try { renderPredictiveAlpha(await callPython("agent_adaptive_alpha_sweep", payload)); }
|
| 1092 |
+
catch (error) { alert(`Adaptation-rate sweep failed: ${error.message}`); }
|
| 1093 |
+
finally { button.disabled = false; button.textContent = "Sweep adaptation rate"; }
|
| 1094 |
+
});
|
| 1095 |
+
$("predictiveAlphaCopy").addEventListener("click", () => { if (lastPredictiveAlpha) copyText(tableText(predictiveAlphaHeaders, predictiveAlphaTableRows(lastPredictiveAlpha)), "Adaptation-rate table copied"); });
|
| 1096 |
+
$("predictiveAlphaCsv").addEventListener("click", () => { if (lastPredictiveAlpha) downloadCsv(`inferscale_adaptation-rate-sweep_${stamp()}.csv`, predictiveAlphaHeaders, predictiveAlphaTableRows(lastPredictiveAlpha)); });
|
| 1097 |
+
|
| 1098 |
function syncAgentControls() {
|
| 1099 |
const retention = $("agentRetention").value;
|
| 1100 |
const routing = $("agentRouting").value;
|
| 1101 |
+
const ttl = retention === "ttl" || adaptive;
|
| 1102 |
+
const adaptive = retention === "adaptive";
|
| 1103 |
+
const hostTier = retention === "offload" || retention === "gap_aware" || adaptive;
|
| 1104 |
+
const gapAware = retention === "gap_aware" || adaptive;
|
| 1105 |
const bounded = routing === "bounded_affinity";
|
| 1106 |
$("agentTtl").disabled = !ttl;
|
| 1107 |
$("agentTtlLabel").classList.toggle("field-disabled", !ttl);
|
|
|
|
| 1111 |
$("agentGapThresholdLabel").classList.toggle("field-disabled", !gapAware);
|
| 1112 |
["agentHostMemory", "agentHostBandwidth", "agentHostBase"].forEach((id) => { $(id).disabled = !hostTier; });
|
| 1113 |
["agentHostMemoryLabel", "agentHostBandwidthLabel", "agentHostBaseLabel"].forEach((id) => { $(id).classList.toggle("field-disabled", !hostTier); });
|
| 1114 |
+
["agentPredictorScope", "agentPredictorAlpha", "agentPredictorMin"].forEach((id) => { $(id).disabled = !adaptive; });
|
| 1115 |
+
["agentPredictorScopeLabel", "agentPredictorAlphaLabel", "agentPredictorMinLabel"].forEach((id) => { $(id).classList.toggle("field-disabled", !adaptive); });
|
| 1116 |
}
|
| 1117 |
$("agentRetention").addEventListener("change", syncAgentControls);
|
| 1118 |
$("agentRouting").addEventListener("change", syncAgentControls);
|
docs/methodology.md
CHANGED
|
@@ -183,3 +183,45 @@ The estimator is intentionally simple and labeled as such. The Affinity Frontier
|
|
| 183 |
The memory-budget experiment first runs a full-retention reference trace and records the unconstrained peak per-replica KV working set. It then expresses stress budgets as multiples of that trace-specific peak rather than arbitrary fractions of total accelerator VRAM. This makes the experiment meaningful even for small models whose default VRAM headroom would otherwise be far larger than the generated KV working set.
|
| 184 |
|
| 185 |
Failed turns and incomplete sessions count against SLO attainment; latency percentiles remain conditional on turns/sessions that actually complete and are accompanied by failure counts in the experiment table.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
The memory-budget experiment first runs a full-retention reference trace and records the unconstrained peak per-replica KV working set. It then expresses stress budgets as multiples of that trace-specific peak rather than arbitrary fractions of total accelerator VRAM. This makes the experiment meaningful even for small models whose default VRAM headroom would otherwise be far larger than the generated KV working set.
|
| 184 |
|
| 185 |
Failed turns and incomplete sessions count against SLO attainment; latency percentiles remain conditional on turns/sessions that actually complete and are accompanied by failure counts in the experiment table.
|
| 186 |
+
|
| 187 |
+
## Adaptive tool-gap prediction
|
| 188 |
+
|
| 189 |
+
Agent traces attach a `tool_kind` to each non-final LLM turn. Reference tool families have different duration scales (`filesystem`, `search`, `database`, and `remote_api`) so a tool-aware predictor has an identifiable structure to learn. Tool identity is synthetic metadata used only by the agent-session simulator.
|
| 190 |
+
|
| 191 |
+
The adaptive policy uses an online exponentially weighted moving average (EWMA). Two scopes are supported:
|
| 192 |
+
|
| 193 |
+
- `global_ema`: one duration estimate shared by all tool families;
|
| 194 |
+
- `per_tool_ema`: one estimate per tool family, with the global estimate used until the per-tool warm-up count is reached.
|
| 195 |
+
|
| 196 |
+
No future tool duration is visible when the retention decision is made. When an LLM turn completes, the adaptive policy predicts the upcoming gap and chooses HBM retention or host offload using the same threshold as the oracle gap-aware baseline. The actual tool duration is added to predictor history only when the simulated tool call completes through a separate `tool_observed` event. A predicted HBM-retain decision is still capped by `kv_ttl_s`; this safety TTL prevents a severe under-prediction from pinning HBM indefinitely.
|
| 197 |
+
|
| 198 |
+
This sequencing is important: directly updating the predictor at LLM-turn completion would leak the already-sampled future gap into the policy and make the adaptive result clairvoyant.
|
| 199 |
+
|
| 200 |
+
## Non-stationary predictive-tiering study
|
| 201 |
+
|
| 202 |
+
The Predictive Tiering Lab generates one common program trace and replays it across fixed and adaptive policies. At a configured fraction of the arrival horizon, the duration scale of slower external tools (`database` and `remote_api`) is multiplied by a user-selected factor. Faster local-style tools remain unchanged.
|
| 203 |
+
|
| 204 |
+
The study compares:
|
| 205 |
+
|
| 206 |
+
1. fixed TTL;
|
| 207 |
+
2. unconditional host offload;
|
| 208 |
+
3. adaptive global EWMA;
|
| 209 |
+
4. adaptive per-tool EWMA;
|
| 210 |
+
5. oracle gap-aware tiering.
|
| 211 |
+
|
| 212 |
+
The oracle is not a deployable contender. It reads the realized future tool gap and exists only to expose an upper bound for the threshold-based decision rule.
|
| 213 |
+
|
| 214 |
+
For adaptive candidates InferScale reports:
|
| 215 |
+
|
| 216 |
+
- mean absolute tool-gap prediction error;
|
| 217 |
+
- pre-shift and post-shift MAE;
|
| 218 |
+
- agreement with the oracle retain/offload action;
|
| 219 |
+
- serving p95 TTFT and session E2E;
|
| 220 |
+
- SLO attainment;
|
| 221 |
+
- HBM/host residency and recomputation.
|
| 222 |
+
|
| 223 |
+
Prediction accuracy is intentionally not treated as the sole objective. Two predictors can have similar MAE while producing different queueing and memory behavior because only errors near the retention threshold change the tiering action.
|
| 224 |
+
|
| 225 |
+
## Adaptation-rate sweep
|
| 226 |
+
|
| 227 |
+
The EWMA alpha sweep replays the exact same shifted trace for every candidate alpha. Small alpha values smooth noise but react slowly after distribution shift; large values react quickly but can overfit individual long-tailed tool calls. InferScale therefore reports both pre-shift and post-shift error alongside system-level latency and memory metrics rather than presenting one global optimum.
|
docs/research.md
CHANGED
|
@@ -122,3 +122,27 @@ SMetric reports that aggressively routing agent turns to replicas that already c
|
|
| 122 |
InferScale's **bounded-affinity** policy is a deliberately simple baseline: it follows the cached replica only while its estimated queue penalty remains within a configurable slack. The Affinity Frontier then sweeps that slack on one common trace.
|
| 123 |
|
| 124 |
Source: https://arxiv.org/abs/2607.08565
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
InferScale's **bounded-affinity** policy is a deliberately simple baseline: it follows the cached replica only while its estimated queue penalty remains within a configurable slack. The Affinity Frontier then sweeps that slack on one common trace.
|
| 123 |
|
| 124 |
Source: https://arxiv.org/abs/2607.08565
|
| 125 |
+
|
| 126 |
+
## Continuum / adaptive TTL for tool calls (May 2026)
|
| 127 |
+
|
| 128 |
+
Continuum treats tool-call duration as a first-class signal for KV retention. It records historical tool-call information, estimates whether a program is likely to return within a candidate TTL, and balances the benefit of avoiding reload/prefill and queueing delay against the opportunity cost of pinning GPU memory. The paper also emphasizes robustness to highly variable tool durations rather than assuming clairvoyant tool completion times.
|
| 129 |
+
|
| 130 |
+
InferScale inspiration: the new adaptive policy learns tool-duration estimates only from completed tool calls, keeps its oracle policy visibly separate, and exposes an explicit non-stationary shift experiment. InferScale's EWMA predictor is deliberately simpler than Continuum's cost/TTL model.
|
| 131 |
+
|
| 132 |
+
Source: https://arxiv.org/abs/2511.02230
|
| 133 |
+
|
| 134 |
+
## Predictive multi-tier KV memory (April/August 2026)
|
| 135 |
+
|
| 136 |
+
Recent predictive KV-memory work combines a multi-tier memory hierarchy with online reuse prediction instead of relying only on reactive eviction. One 2026 design uses a Bayesian reuse predictor with per-class priors to guide eviction and prefetching across a much richer storage hierarchy than InferScale models.
|
| 137 |
+
|
| 138 |
+
InferScale inspiration: measure prediction quality and systems outcomes together, and keep HBM/host placement decisions explicit. InferScale currently models only HBM plus host DRAM and uses transparent EWMA duration prediction; it does not reproduce the paper's six-tier hierarchy or Bayesian predictor.
|
| 139 |
+
|
| 140 |
+
Source: https://arxiv.org/abs/2604.26968
|
| 141 |
+
|
| 142 |
+
## CacheScout / learning agent execution online (July 2026)
|
| 143 |
+
|
| 144 |
+
CacheScout argues that future KV reuse in multi-agent systems is governed by execution semantics rather than recency alone. It learns agent execution transitions online without requiring predefined workflow graphs or offline training, then uses the learned model for eviction and proactive prefetching.
|
| 145 |
+
|
| 146 |
+
InferScale inspiration: move beyond an oracle or fixed TTL toward a policy that actually learns from runtime history. The current Predictive Tiering Lab learns tool-duration statistics, not workflow-transition graphs or prefetch decisions, so CacheScout remains a direction for deeper future work rather than a claimed reproduction.
|
| 147 |
+
|
| 148 |
+
Source: https://arxiv.org/abs/2608.14624
|
index.html
CHANGED
|
@@ -363,15 +363,18 @@
|
|
| 363 |
<hr />
|
| 364 |
<div class="section-kicker">State policy</div>
|
| 365 |
<div class="field-grid two">
|
| 366 |
-
<label>KV retention<select id="agentRetention"><option value="evict">Evict after every turn</option><option value="ttl" selected>TTL retention</option><option value="retain">Retain until session ends</option><option value="offload">Offload idle KV to host</option><option value="gap_aware">Oracle gap-aware HBM / host tiering</option></select></label>
|
| 367 |
<label>Routing<select id="agentRouting"><option value="least_load">Least-load</option><option value="session_affinity" selected>Strict session affinity</option><option value="bounded_affinity">Bounded affinity</option></select></label>
|
| 368 |
-
<label id="agentTtlLabel">KV TTL<input id="agentTtl" type="number" min="0" step="0.25" value="3" /><span class="unit">s</span></label>
|
| 369 |
<label>KV memory fraction<input id="agentKvFraction" type="number" min="0.01" max="0.98" step="0.05" value="0.85" /><span class="unit">fraction</span></label>
|
| 370 |
<label id="agentAffinitySlackLabel">Affinity slack<input id="agentAffinitySlack" type="number" min="0" step="25" value="150" /><span class="unit">ms</span></label>
|
| 371 |
<label id="agentGapThresholdLabel">Gap-aware HBM threshold<input id="agentGapThreshold" type="number" min="0" step="0.25" value="1.5" /><span class="unit">s</span></label>
|
| 372 |
<label id="agentHostMemoryLabel">Host KV capacity<input id="agentHostMemory" type="number" min="0" step="4" value="32" /><span class="unit">GB</span></label>
|
| 373 |
<label id="agentHostBandwidthLabel">Host transfer bandwidth<input id="agentHostBandwidth" type="number" min="0.1" step="1" value="32" /><span class="unit">GB/s</span></label>
|
| 374 |
<label id="agentHostBaseLabel">Transfer base latency<input id="agentHostBase" type="number" min="0" step="0.05" value="0.15" /><span class="unit">ms</span></label>
|
|
|
|
|
|
|
|
|
|
| 375 |
</div>
|
| 376 |
<div class="field-grid two">
|
| 377 |
<label>Turn TTFT SLO<input id="agentTtftSlo" type="number" min="1" value="500" /><span class="unit">ms</span></label>
|
|
@@ -470,6 +473,48 @@
|
|
| 470 |
</div>
|
| 471 |
</section>
|
| 472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
</div>
|
| 474 |
</div>
|
| 475 |
</section>
|
|
@@ -480,10 +525,11 @@
|
|
| 480 |
<article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>The simulator models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
|
| 481 |
<article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
|
| 482 |
<article class="panel prose"><div class="section-kicker">Stateful agent sessions</div><h2>Tool gaps turn KV into a residency and routing decision</h2><p>Agent Sessions preserves program identity and turn order, materializes tool-induced gaps, and tracks HBM retention, TTL expiry, host-memory offload/restore, recomputation, and pressure eviction. Strict affinity, least-load, and bounded-affinity routing expose the tension between cache locality and hot-replica queueing. The per-replica service model remains intentionally serial so state-management effects are not confounded with dynamic batching.</p></article>
|
|
|
|
| 483 |
<article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
|
| 484 |
<article class="panel prose"><div class="section-kicker">Trace methodology</div><h2>Generated load or exact replay</h2><p>Poisson, constant, and bursty workloads are open-loop: arrivals are scheduled independently of response completion, so queueing delay is not hidden by client backpressure. Exact CSV/JSON traces can be replayed with their original arrival times and token lengths.</p></article>
|
| 485 |
<article class="panel prose"><div class="section-kicker">Research protocol</div><h2>Paired conclusions, not one lucky seed</h2><p>Research Studies use common random numbers: baseline and treatment receive identical seeds, reducing workload variance in the paired difference. A bootstrap interval summarizes the repeated effect, while a separate sensitivity study perturbs analytical latency scales to reveal conclusions that depend too strongly on one reference profile.</p></article>
|
| 486 |
-
<article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Research lineage and scope</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, stateful workloads, trace replay, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable, with paired experiments and sensitivity analysis built into the workflow.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>Vanguard / Jun 2026</strong><span>Open-loop replay avoids coordinated omission when studying latency under load.</span></div><div><strong>AgentServeSim / Jun 2026</strong><span>Stateful multi-turn serving motivates session-aware workload modeling.</span></div><div><strong>IdleKV / Jun 2026</strong><span>Tool-call idle windows motivate explicit HBM-to-host KV offload experiments.</span></div><div><strong>SMetric / Jul 2026</strong><span>Cache-local routing can overload hot replicas, motivating bounded affinity.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
|
| 487 |
</div>
|
| 488 |
</section>
|
| 489 |
</main>
|
|
|
|
| 363 |
<hr />
|
| 364 |
<div class="section-kicker">State policy</div>
|
| 365 |
<div class="field-grid two">
|
| 366 |
+
<label>KV retention<select id="agentRetention"><option value="evict">Evict after every turn</option><option value="ttl" selected>TTL retention</option><option value="retain">Retain until session ends</option><option value="offload">Offload idle KV to host</option><option value="gap_aware">Oracle gap-aware HBM / host tiering</option><option value="adaptive">Adaptive tool-gap predictor</option></select></label>
|
| 367 |
<label>Routing<select id="agentRouting"><option value="least_load">Least-load</option><option value="session_affinity" selected>Strict session affinity</option><option value="bounded_affinity">Bounded affinity</option></select></label>
|
| 368 |
+
<label id="agentTtlLabel">KV / adaptive safety TTL<input id="agentTtl" type="number" min="0" step="0.25" value="3" /><span class="unit">s</span></label>
|
| 369 |
<label>KV memory fraction<input id="agentKvFraction" type="number" min="0.01" max="0.98" step="0.05" value="0.85" /><span class="unit">fraction</span></label>
|
| 370 |
<label id="agentAffinitySlackLabel">Affinity slack<input id="agentAffinitySlack" type="number" min="0" step="25" value="150" /><span class="unit">ms</span></label>
|
| 371 |
<label id="agentGapThresholdLabel">Gap-aware HBM threshold<input id="agentGapThreshold" type="number" min="0" step="0.25" value="1.5" /><span class="unit">s</span></label>
|
| 372 |
<label id="agentHostMemoryLabel">Host KV capacity<input id="agentHostMemory" type="number" min="0" step="4" value="32" /><span class="unit">GB</span></label>
|
| 373 |
<label id="agentHostBandwidthLabel">Host transfer bandwidth<input id="agentHostBandwidth" type="number" min="0.1" step="1" value="32" /><span class="unit">GB/s</span></label>
|
| 374 |
<label id="agentHostBaseLabel">Transfer base latency<input id="agentHostBase" type="number" min="0" step="0.05" value="0.15" /><span class="unit">ms</span></label>
|
| 375 |
+
<label id="agentPredictorScopeLabel">Adaptive predictor<select id="agentPredictorScope"><option value="per_tool_ema" selected>Per-tool EWMA</option><option value="global_ema">Global EWMA</option></select></label>
|
| 376 |
+
<label id="agentPredictorAlphaLabel">EWMA alpha<input id="agentPredictorAlpha" type="number" min="0.01" max="1" step="0.05" value="0.30" /><span class="unit">fraction</span></label>
|
| 377 |
+
<label id="agentPredictorMinLabel">Warm-up observations<input id="agentPredictorMin" type="number" min="1" max="20" step="1" value="2" /></label>
|
| 378 |
</div>
|
| 379 |
<div class="field-grid two">
|
| 380 |
<label>Turn TTFT SLO<input id="agentTtftSlo" type="number" min="1" value="500" /><span class="unit">ms</span></label>
|
|
|
|
| 473 |
</div>
|
| 474 |
</section>
|
| 475 |
|
| 476 |
+
<section class="panel research-panel">
|
| 477 |
+
<div class="panel-title-row"><div><div class="section-kicker">Online prediction under workload shift</div><h2>Predictive Tiering Lab</h2><p class="muted">Replace the oracle tool-gap policy with online estimates learned only from completed tool calls. The study changes slow-tool latency partway through one common program trace, then measures whether global and per-tool predictors adapt without future-duration leakage.</p></div></div>
|
| 478 |
+
<div class="predictive-controls field-grid four">
|
| 479 |
+
<label>Study horizon<input id="predictiveHorizon" type="number" min="30" max="600" step="30" value="120" /><span class="unit">sim s</span></label>
|
| 480 |
+
<label>Shift point<input id="predictiveShift" type="number" min="0.1" max="0.9" step="0.05" value="0.55" /><span class="unit">fraction</span></label>
|
| 481 |
+
<label>Slow-tool multiplier<input id="predictiveMultiplier" type="number" min="1" max="6" step="0.25" value="2.5" /><span class="unit">x</span></label>
|
| 482 |
+
<label>Adaptive alpha<input id="predictiveAlpha" type="number" min="0.01" max="1" step="0.05" value="0.30" /><span class="unit">fraction</span></label>
|
| 483 |
+
</div>
|
| 484 |
+
<div class="stacked-actions horizontal-actions"><button id="predictiveCompareBtn" class="primary compact" disabled>Compare predictive policies</button><button id="predictiveAlphaBtn" class="primary compact" disabled>Sweep adaptation rate</button></div>
|
| 485 |
+
<div id="predictiveEmpty" class="empty-state small"><h3>No predictive-tiering experiment yet</h3><p>The adaptive policies use exponentially weighted tool-duration history. The oracle candidate is retained only as an upper bound; it can see the realized future gap while adaptive candidates cannot.</p></div>
|
| 486 |
+
<div id="predictiveContent" class="hidden">
|
| 487 |
+
<div id="predictiveCompareBlock" class="hidden">
|
| 488 |
+
<div class="metric-grid four">
|
| 489 |
+
<div class="metric emphasis"><span>Best non-oracle p95 TTFT</span><strong id="predictiveBestTtft">N/A</strong></div>
|
| 490 |
+
<div class="metric"><span>Per-tool oracle agreement</span><strong id="predictiveAgreement">N/A</strong></div>
|
| 491 |
+
<div class="metric"><span>Post-shift MAE</span><strong id="predictivePostMae">N/A</strong></div>
|
| 492 |
+
<div class="metric"><span>Shift point</span><strong id="predictiveShiftValue">N/A</strong></div>
|
| 493 |
+
</div>
|
| 494 |
+
<div class="chart-grid">
|
| 495 |
+
<div class="chart-card" data-chart-card data-chart-name="agent-predictive-policy-tradeoff">
|
| 496 |
+
<div class="chart-head"><div class="chart-title">Mean HBM residency vs p95 turn TTFT</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 497 |
+
<div class="chart-body research-chart"><canvas id="predictivePolicyChart"></canvas></div>
|
| 498 |
+
</div>
|
| 499 |
+
<div class="chart-card" data-chart-card data-chart-name="agent-tool-gap-learning-curve">
|
| 500 |
+
<div class="chart-head"><div class="chart-title">Online tool-gap prediction error</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 501 |
+
<div class="chart-body research-chart"><canvas id="predictiveLearningChart"></canvas></div>
|
| 502 |
+
</div>
|
| 503 |
+
</div>
|
| 504 |
+
<div class="table-toolbar"><span>Common shifted-trace policy results</span><div><button id="predictiveCopy" class="mini-button" disabled>Copy table</button><button id="predictiveCsv" class="mini-button" disabled>Download CSV</button></div></div>
|
| 505 |
+
<div class="table-wrap"><table><thead><tr><th>Policy</th><th>p95 turn TTFT</th><th>p95 session E2E</th><th>Session SLO</th><th>Reuse</th><th>Mean HBM</th><th>Mean host</th><th>Prediction MAE</th><th>Oracle agreement</th><th>Post-shift MAE</th><th>Recomputed</th></tr></thead><tbody id="predictiveRows"></tbody></table></div>
|
| 506 |
+
</div>
|
| 507 |
+
<div id="predictiveAlphaBlock" class="hidden experiment-subsection">
|
| 508 |
+
<div class="chart-card full" data-chart-card data-chart-name="agent-adaptation-rate-sweep">
|
| 509 |
+
<div class="chart-head"><div class="chart-title">EWMA alpha vs prediction error before and after shift</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
|
| 510 |
+
<div class="chart-body research-chart"><canvas id="predictiveAlphaChart"></canvas></div>
|
| 511 |
+
</div>
|
| 512 |
+
<div class="table-toolbar"><span id="predictiveAlphaCaption">Adaptation-rate sweep</span><div><button id="predictiveAlphaCopy" class="mini-button" disabled>Copy table</button><button id="predictiveAlphaCsv" class="mini-button" disabled>Download CSV</button></div></div>
|
| 513 |
+
<div class="table-wrap"><table><thead><tr><th>Alpha</th><th>Pre-shift MAE</th><th>Post-shift MAE</th><th>Oracle agreement</th><th>p95 TTFT</th><th>p95 session E2E</th><th>Session SLO</th><th>Mean HBM</th><th>Mean host</th></tr></thead><tbody id="predictiveAlphaRows"></tbody></table></div>
|
| 514 |
+
</div>
|
| 515 |
+
</div>
|
| 516 |
+
</section>
|
| 517 |
+
|
| 518 |
</div>
|
| 519 |
</div>
|
| 520 |
</section>
|
|
|
|
| 525 |
<article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>The simulator models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
|
| 526 |
<article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
|
| 527 |
<article class="panel prose"><div class="section-kicker">Stateful agent sessions</div><h2>Tool gaps turn KV into a residency and routing decision</h2><p>Agent Sessions preserves program identity and turn order, materializes tool-induced gaps, and tracks HBM retention, TTL expiry, host-memory offload/restore, recomputation, and pressure eviction. Strict affinity, least-load, and bounded-affinity routing expose the tension between cache locality and hot-replica queueing. The per-replica service model remains intentionally serial so state-management effects are not confounded with dynamic batching.</p></article>
|
| 528 |
+
<article class="panel prose"><div class="section-kicker">Online adaptive tiering</div><h2>Prediction without future-gap leakage</h2><p>Adaptive tiering learns an exponentially weighted estimate from tool calls only after they complete. Global and per-tool predictors can be evaluated on the same non-stationary program trace, while an oracle policy that sees the realized future gap is kept separate as an upper bound. A shift experiment changes slower external-tool durations mid-trace to expose the stability-versus-adaptation trade-off.</p></article>
|
| 529 |
<article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
|
| 530 |
<article class="panel prose"><div class="section-kicker">Trace methodology</div><h2>Generated load or exact replay</h2><p>Poisson, constant, and bursty workloads are open-loop: arrivals are scheduled independently of response completion, so queueing delay is not hidden by client backpressure. Exact CSV/JSON traces can be replayed with their original arrival times and token lengths.</p></article>
|
| 531 |
<article class="panel prose"><div class="section-kicker">Research protocol</div><h2>Paired conclusions, not one lucky seed</h2><p>Research Studies use common random numbers: baseline and treatment receive identical seeds, reducing workload variance in the paired difference. A bootstrap interval summarizes the repeated effect, while a separate sensitivity study perturbs analytical latency scales to reveal conclusions that depend too strongly on one reference profile.</p></article>
|
| 532 |
+
<article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Research lineage and scope</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, stateful workloads, trace replay, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable, with paired experiments and sensitivity analysis built into the workflow.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>Vanguard / Jun 2026</strong><span>Open-loop replay avoids coordinated omission when studying latency under load.</span></div><div><strong>AgentServeSim / Jun 2026</strong><span>Stateful multi-turn serving motivates session-aware workload modeling.</span></div><div><strong>IdleKV / Jun 2026</strong><span>Tool-call idle windows motivate explicit HBM-to-host KV offload experiments.</span></div><div><strong>SMetric / Jul 2026</strong><span>Cache-local routing can overload hot replicas, motivating bounded affinity.</span></div><div><strong>Continuum / May 2026</strong><span>Per-tool duration history and bounded TTL motivate adaptive retention without clairvoyance.</span></div><div><strong>CacheScout / Jul 2026</strong><span>Online learning of agent execution semantics motivates predictive rather than purely reactive KV management.</span></div><div><strong>Predictive KV Memory / Aug 2026 rev.</strong><span>Bayesian reuse prediction and multi-tier placement motivate explicit prediction-quality experiments.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
|
| 533 |
</div>
|
| 534 |
</section>
|
| 535 |
</main>
|
py/inferscale/__init__.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
from .agentic import (
|
|
|
|
|
|
|
| 2 |
agent_affinity_sweep,
|
| 3 |
agent_memory_budget_sweep,
|
| 4 |
compare_agent_memory_policies,
|
|
@@ -15,6 +17,8 @@ from .validation import validate_cases
|
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
"SimulationConfig",
|
|
|
|
|
|
|
| 18 |
"agent_affinity_sweep",
|
| 19 |
"agent_memory_budget_sweep",
|
| 20 |
"compare_agent_memory_policies",
|
|
@@ -35,4 +39,4 @@ __all__ = [
|
|
| 35 |
|
| 36 |
# Internal package metadata only; the public project intentionally avoids
|
| 37 |
# release/version branding in the interface and documentation.
|
| 38 |
-
__version__ = "0.
|
|
|
|
| 1 |
from .agentic import (
|
| 2 |
+
adaptive_alpha_sweep,
|
| 3 |
+
adaptive_tiering_study,
|
| 4 |
agent_affinity_sweep,
|
| 5 |
agent_memory_budget_sweep,
|
| 6 |
compare_agent_memory_policies,
|
|
|
|
| 17 |
|
| 18 |
__all__ = [
|
| 19 |
"SimulationConfig",
|
| 20 |
+
"adaptive_alpha_sweep",
|
| 21 |
+
"adaptive_tiering_study",
|
| 22 |
"agent_affinity_sweep",
|
| 23 |
"agent_memory_budget_sweep",
|
| 24 |
"compare_agent_memory_policies",
|
|
|
|
| 39 |
|
| 40 |
# Internal package metadata only; the public project intentionally avoids
|
| 41 |
# release/version branding in the interface and documentation.
|
| 42 |
+
__version__ = "0.7.0"
|
py/inferscale/agentic.py
CHANGED
|
@@ -9,11 +9,20 @@ from typing import Any
|
|
| 9 |
|
| 10 |
from .latency import AnalyticalLatencyModel
|
| 11 |
from .metrics import percentile
|
|
|
|
| 12 |
from .profiles import get_accelerator, get_model
|
| 13 |
|
| 14 |
|
| 15 |
ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
|
| 16 |
-
RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@dataclass
|
|
@@ -44,6 +53,11 @@ class AgentSessionConfig:
|
|
| 44 |
host_transfer_base_ms: float = 0.15
|
| 45 |
affinity_slack_ms: float = 150.0
|
| 46 |
gap_aware_threshold_s: float = 1.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
slo_turn_ttft_ms: float = 500.0
|
| 48 |
slo_session_e2e_ms: float = 30000.0
|
| 49 |
timeline_points: int = 240
|
|
@@ -67,6 +81,12 @@ class AgentSessionConfig:
|
|
| 67 |
cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
|
| 68 |
cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
|
| 69 |
cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
return cfg
|
| 71 |
|
| 72 |
def to_dict(self) -> dict[str, Any]:
|
|
@@ -80,6 +100,8 @@ class TurnSpec:
|
|
| 80 |
append_tokens: int
|
| 81 |
output_tokens: int
|
| 82 |
tool_gap_after_s: float
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
@dataclass
|
|
@@ -161,13 +183,23 @@ def generate_agent_sessions(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
|
| 161 |
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 162 |
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 163 |
specs = []
|
|
|
|
|
|
|
| 164 |
for turn_idx in range(turns):
|
| 165 |
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 166 |
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 172 |
sid += 1
|
| 173 |
return sessions
|
|
@@ -224,6 +256,13 @@ class AgentSessionSimulator:
|
|
| 224 |
self.route_opportunities = 0
|
| 225 |
self.failed_turns = 0
|
| 226 |
self.tool_gap_total_s = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
for session in specs:
|
| 228 |
if session.turns:
|
| 229 |
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
|
@@ -406,7 +445,7 @@ class AgentSessionSimulator:
|
|
| 406 |
previous = replica.cache.get(session_id)
|
| 407 |
generation = (previous.generation + 1) if previous else 1
|
| 408 |
expiry: float | None = None
|
| 409 |
-
if keep and self.cfg.retention_policy
|
| 410 |
expiry = self.now + self.cfg.kv_ttl_s
|
| 411 |
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 412 |
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
|
@@ -542,6 +581,31 @@ class AgentSessionSimulator:
|
|
| 542 |
state_action = "offload_host_long_gap"
|
| 543 |
else:
|
| 544 |
state_action = "evict_host_full"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
e2e_ms = (self.now - ready_time) * 1000.0
|
| 547 |
self.turn_rows.append(
|
|
@@ -555,6 +619,9 @@ class AgentSessionSimulator:
|
|
| 555 |
"cache_source": cache_source,
|
| 556 |
"restore_ms": restore_s * 1000.0,
|
| 557 |
"state_action": state_action,
|
|
|
|
|
|
|
|
|
|
| 558 |
"prefill_tokens": prefill_tokens,
|
| 559 |
"context_tokens_after": projected_tokens,
|
| 560 |
"output_tokens": turn.output_tokens,
|
|
@@ -568,7 +635,9 @@ class AgentSessionSimulator:
|
|
| 568 |
runtime.completion_time = self.now
|
| 569 |
else:
|
| 570 |
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 571 |
-
self.
|
|
|
|
|
|
|
| 572 |
self._start_next(replica)
|
| 573 |
|
| 574 |
def run(self) -> dict[str, Any]:
|
|
@@ -577,7 +646,9 @@ class AgentSessionSimulator:
|
|
| 577 |
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 578 |
self._integrate_memory(event_time)
|
| 579 |
self.now = event_time
|
| 580 |
-
if kind == "
|
|
|
|
|
|
|
| 581 |
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 582 |
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 583 |
replica = self._route(session_id)
|
|
@@ -641,6 +712,8 @@ class AgentSessionSimulator:
|
|
| 641 |
}
|
| 642 |
host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
|
| 643 |
total_reuse_hits = self.cache_hits + self.host_cache_hits
|
|
|
|
|
|
|
| 644 |
resource = {
|
| 645 |
"replicas": self.cfg.replicas,
|
| 646 |
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
|
@@ -667,6 +740,13 @@ class AgentSessionSimulator:
|
|
| 667 |
"restored_gb": self.restore_bytes / 1e9,
|
| 668 |
"p95_host_transfer_ms": host_restore_p95_ms,
|
| 669 |
"tool_gap_total_s": self.tool_gap_total_s,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
}
|
| 671 |
return {
|
| 672 |
"config": self.cfg.to_dict(),
|
|
@@ -677,12 +757,19 @@ class AgentSessionSimulator:
|
|
| 677 |
"agent_service_model": "serial-per-replica-reference",
|
| 678 |
"host_tier_model": "serialized-reference-transfer",
|
| 679 |
"gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
|
|
|
|
|
|
|
|
|
|
| 680 |
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 681 |
},
|
| 682 |
"summary": summary,
|
| 683 |
"latency": latency,
|
| 684 |
"resource": resource,
|
| 685 |
"turns": successful_turns[:3000],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
"sessions": [
|
| 687 |
{
|
| 688 |
"session_id": s.spec.session_id,
|
|
@@ -957,3 +1044,192 @@ def agent_affinity_sweep(config: dict[str, Any], slack_values_ms: list[float] |
|
|
| 957 |
"study": "bounded-affinity-routing-frontier",
|
| 958 |
"rows": rows,
|
| 959 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from .latency import AnalyticalLatencyModel
|
| 11 |
from .metrics import percentile
|
| 12 |
+
from .prediction import OnlineToolGapPredictor, PREDICTOR_SCOPES
|
| 13 |
from .profiles import get_accelerator, get_model
|
| 14 |
|
| 15 |
|
| 16 |
ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
|
| 17 |
+
RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware", "adaptive"}
|
| 18 |
+
|
| 19 |
+
TOOL_GAP_MULTIPLIERS = {
|
| 20 |
+
"filesystem": 0.45,
|
| 21 |
+
"search": 0.80,
|
| 22 |
+
"database": 1.25,
|
| 23 |
+
"remote_api": 2.20,
|
| 24 |
+
}
|
| 25 |
+
TOOL_WEIGHTS = (0.28, 0.32, 0.22, 0.18)
|
| 26 |
|
| 27 |
|
| 28 |
@dataclass
|
|
|
|
| 53 |
host_transfer_base_ms: float = 0.15
|
| 54 |
affinity_slack_ms: float = 150.0
|
| 55 |
gap_aware_threshold_s: float = 1.5
|
| 56 |
+
adaptive_predictor_scope: str = "per_tool_ema"
|
| 57 |
+
adaptive_alpha: float = 0.30
|
| 58 |
+
adaptive_min_observations: int = 2
|
| 59 |
+
tool_regime_shift_fraction: float = 0.0
|
| 60 |
+
tool_regime_shift_multiplier: float = 1.0
|
| 61 |
slo_turn_ttft_ms: float = 500.0
|
| 62 |
slo_session_e2e_ms: float = 30000.0
|
| 63 |
timeline_points: int = 240
|
|
|
|
| 81 |
cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
|
| 82 |
cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
|
| 83 |
cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
|
| 84 |
+
if cfg.adaptive_predictor_scope not in PREDICTOR_SCOPES:
|
| 85 |
+
raise ValueError(f"Unsupported adaptive predictor scope: {cfg.adaptive_predictor_scope}")
|
| 86 |
+
cfg.adaptive_alpha = min(max(float(cfg.adaptive_alpha), 0.01), 1.0)
|
| 87 |
+
cfg.adaptive_min_observations = max(1, int(cfg.adaptive_min_observations))
|
| 88 |
+
cfg.tool_regime_shift_fraction = min(max(float(cfg.tool_regime_shift_fraction), 0.0), 0.95)
|
| 89 |
+
cfg.tool_regime_shift_multiplier = max(float(cfg.tool_regime_shift_multiplier), 0.1)
|
| 90 |
return cfg
|
| 91 |
|
| 92 |
def to_dict(self) -> dict[str, Any]:
|
|
|
|
| 100 |
append_tokens: int
|
| 101 |
output_tokens: int
|
| 102 |
tool_gap_after_s: float
|
| 103 |
+
tool_kind: str = "generic"
|
| 104 |
+
shifted_regime: bool = False
|
| 105 |
|
| 106 |
|
| 107 |
@dataclass
|
|
|
|
| 183 |
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 184 |
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 185 |
specs = []
|
| 186 |
+
tool_names = tuple(TOOL_GAP_MULTIPLIERS)
|
| 187 |
+
shifted = cfg.tool_regime_shift_fraction > 0.0 and now >= cfg.duration_s * cfg.tool_regime_shift_fraction
|
| 188 |
for turn_idx in range(turns):
|
| 189 |
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 190 |
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 191 |
+
tool_kind = rng.choices(tool_names, weights=TOOL_WEIGHTS, k=1)[0]
|
| 192 |
+
if turn_idx == turns - 1:
|
| 193 |
+
gap = 0.0
|
| 194 |
+
else:
|
| 195 |
+
mean_gap = cfg.tool_gap_mean_s * TOOL_GAP_MULTIPLIERS[tool_kind]
|
| 196 |
+
# The optional shift deliberately changes only the slower external
|
| 197 |
+
# tools. This creates a non-stationary workload where a global
|
| 198 |
+
# duration average is less informative than tool-aware history.
|
| 199 |
+
if shifted and tool_kind in {"database", "remote_api"}:
|
| 200 |
+
mean_gap *= cfg.tool_regime_shift_multiplier
|
| 201 |
+
gap = _sample_positive_lognormal(rng, mean_gap, cfg.tool_gap_cv, 0.0)
|
| 202 |
+
specs.append(TurnSpec(sid, turn_idx, append, output, gap, tool_kind, shifted))
|
| 203 |
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 204 |
sid += 1
|
| 205 |
return sessions
|
|
|
|
| 256 |
self.route_opportunities = 0
|
| 257 |
self.failed_turns = 0
|
| 258 |
self.tool_gap_total_s = 0.0
|
| 259 |
+
self.tool_gap_predictor = OnlineToolGapPredictor(
|
| 260 |
+
initial_mean_s=cfg.tool_gap_mean_s,
|
| 261 |
+
alpha=cfg.adaptive_alpha,
|
| 262 |
+
min_observations=cfg.adaptive_min_observations,
|
| 263 |
+
scope=cfg.adaptive_predictor_scope,
|
| 264 |
+
)
|
| 265 |
+
self.prediction_rows: list[dict[str, Any]] = []
|
| 266 |
for session in specs:
|
| 267 |
if session.turns:
|
| 268 |
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
|
|
|
| 445 |
previous = replica.cache.get(session_id)
|
| 446 |
generation = (previous.generation + 1) if previous else 1
|
| 447 |
expiry: float | None = None
|
| 448 |
+
if keep and self.cfg.retention_policy in {"ttl", "adaptive"}:
|
| 449 |
expiry = self.now + self.cfg.kv_ttl_s
|
| 450 |
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 451 |
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
|
|
|
| 581 |
state_action = "offload_host_long_gap"
|
| 582 |
else:
|
| 583 |
state_action = "evict_host_full"
|
| 584 |
+
elif self.cfg.retention_policy == "adaptive":
|
| 585 |
+
predicted_gap_s, prediction_source, history_count = self.tool_gap_predictor.predict(turn.tool_kind)
|
| 586 |
+
predicted_retain = predicted_gap_s <= self.cfg.gap_aware_threshold_s
|
| 587 |
+
oracle_retain = turn.tool_gap_after_s <= self.cfg.gap_aware_threshold_s
|
| 588 |
+
if predicted_retain:
|
| 589 |
+
self._put_cache(replica, session_id, projected_tokens, keep=True)
|
| 590 |
+
state_action = "adaptive_retain_hbm"
|
| 591 |
+
elif self._offload_cache(replica, session_id, projected_tokens):
|
| 592 |
+
state_action = "adaptive_offload_host"
|
| 593 |
+
else:
|
| 594 |
+
state_action = "adaptive_evict_host_full"
|
| 595 |
+
self.prediction_rows.append({
|
| 596 |
+
"session_id": session_id,
|
| 597 |
+
"turn_index": turn_index + 1,
|
| 598 |
+
"tool_kind": turn.tool_kind,
|
| 599 |
+
"shifted_regime": turn.shifted_regime,
|
| 600 |
+
"predicted_gap_s": predicted_gap_s,
|
| 601 |
+
"actual_gap_s": turn.tool_gap_after_s,
|
| 602 |
+
"absolute_error_s": abs(predicted_gap_s - turn.tool_gap_after_s),
|
| 603 |
+
"prediction_source": prediction_source,
|
| 604 |
+
"history_count": history_count,
|
| 605 |
+
"predicted_action": "retain" if predicted_retain else "offload",
|
| 606 |
+
"oracle_action": "retain" if oracle_retain else "offload",
|
| 607 |
+
"action_match": predicted_retain == oracle_retain,
|
| 608 |
+
})
|
| 609 |
|
| 610 |
e2e_ms = (self.now - ready_time) * 1000.0
|
| 611 |
self.turn_rows.append(
|
|
|
|
| 619 |
"cache_source": cache_source,
|
| 620 |
"restore_ms": restore_s * 1000.0,
|
| 621 |
"state_action": state_action,
|
| 622 |
+
"tool_kind": turn.tool_kind,
|
| 623 |
+
"tool_gap_after_s": turn.tool_gap_after_s,
|
| 624 |
+
"shifted_regime": turn.shifted_regime,
|
| 625 |
"prefill_tokens": prefill_tokens,
|
| 626 |
"context_tokens_after": projected_tokens,
|
| 627 |
"output_tokens": turn.output_tokens,
|
|
|
|
| 635 |
runtime.completion_time = self.now
|
| 636 |
else:
|
| 637 |
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 638 |
+
observed_at = self.now + turn.tool_gap_after_s
|
| 639 |
+
self._push(observed_at, "tool_observed", turn.tool_kind, turn.tool_gap_after_s)
|
| 640 |
+
self._push(observed_at, "turn_ready", session_id, turn_index + 1)
|
| 641 |
self._start_next(replica)
|
| 642 |
|
| 643 |
def run(self) -> dict[str, Any]:
|
|
|
|
| 646 |
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 647 |
self._integrate_memory(event_time)
|
| 648 |
self.now = event_time
|
| 649 |
+
if kind == "tool_observed":
|
| 650 |
+
self.tool_gap_predictor.observe(str(payload[0]), float(payload[1]))
|
| 651 |
+
elif kind == "turn_ready":
|
| 652 |
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 653 |
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 654 |
replica = self._route(session_id)
|
|
|
|
| 712 |
}
|
| 713 |
host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
|
| 714 |
total_reuse_hits = self.cache_hits + self.host_cache_hits
|
| 715 |
+
prediction_errors = [float(row["absolute_error_s"]) for row in self.prediction_rows]
|
| 716 |
+
prediction_matches = [bool(row["action_match"]) for row in self.prediction_rows]
|
| 717 |
resource = {
|
| 718 |
"replicas": self.cfg.replicas,
|
| 719 |
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
|
|
|
| 740 |
"restored_gb": self.restore_bytes / 1e9,
|
| 741 |
"p95_host_transfer_ms": host_restore_p95_ms,
|
| 742 |
"tool_gap_total_s": self.tool_gap_total_s,
|
| 743 |
+
"adaptive_prediction_count": len(self.prediction_rows),
|
| 744 |
+
"adaptive_prediction_mae_s": mean(prediction_errors) if prediction_errors else 0.0,
|
| 745 |
+
"adaptive_prediction_p95_abs_error_s": percentile(prediction_errors, 0.95),
|
| 746 |
+
"adaptive_oracle_action_agreement": (
|
| 747 |
+
sum(1 for match in prediction_matches if match) / len(prediction_matches)
|
| 748 |
+
if prediction_matches else 0.0
|
| 749 |
+
),
|
| 750 |
}
|
| 751 |
return {
|
| 752 |
"config": self.cfg.to_dict(),
|
|
|
|
| 757 |
"agent_service_model": "serial-per-replica-reference",
|
| 758 |
"host_tier_model": "serialized-reference-transfer",
|
| 759 |
"gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
|
| 760 |
+
"adaptive_policy": (
|
| 761 |
+
"online-tool-gap-ewma-no-lookahead" if self.cfg.retention_policy == "adaptive" else "not-active"
|
| 762 |
+
),
|
| 763 |
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 764 |
},
|
| 765 |
"summary": summary,
|
| 766 |
"latency": latency,
|
| 767 |
"resource": resource,
|
| 768 |
"turns": successful_turns[:3000],
|
| 769 |
+
"prediction": {
|
| 770 |
+
"rows": self.prediction_rows[:3000],
|
| 771 |
+
"predictor": self.tool_gap_predictor.snapshot(),
|
| 772 |
+
},
|
| 773 |
"sessions": [
|
| 774 |
{
|
| 775 |
"session_id": s.spec.session_id,
|
|
|
|
| 1044 |
"study": "bounded-affinity-routing-frontier",
|
| 1045 |
"rows": rows,
|
| 1046 |
}
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
def _prediction_phase_metrics(result: dict[str, Any]) -> dict[str, float]:
|
| 1050 |
+
rows = result.get("prediction", {}).get("rows", [])
|
| 1051 |
+
before = [row for row in rows if not row.get("shifted_regime")]
|
| 1052 |
+
after = [row for row in rows if row.get("shifted_regime")]
|
| 1053 |
+
|
| 1054 |
+
def phase(items: list[dict[str, Any]]) -> tuple[float, float]:
|
| 1055 |
+
if not items:
|
| 1056 |
+
return 0.0, 0.0
|
| 1057 |
+
mae = mean(float(row["absolute_error_s"]) for row in items)
|
| 1058 |
+
agreement = sum(1 for row in items if row.get("action_match")) / len(items)
|
| 1059 |
+
return mae, agreement
|
| 1060 |
+
|
| 1061 |
+
pre_mae, pre_agreement = phase(before)
|
| 1062 |
+
post_mae, post_agreement = phase(after)
|
| 1063 |
+
return {
|
| 1064 |
+
"pre_shift_mae_s": pre_mae,
|
| 1065 |
+
"post_shift_mae_s": post_mae,
|
| 1066 |
+
"pre_shift_action_agreement": pre_agreement,
|
| 1067 |
+
"post_shift_action_agreement": post_agreement,
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def _rolling_prediction_curve(result: dict[str, Any], window: int = 16) -> list[dict[str, Any]]:
|
| 1072 |
+
rows = result.get("prediction", {}).get("rows", [])
|
| 1073 |
+
if not rows:
|
| 1074 |
+
return []
|
| 1075 |
+
points: list[dict[str, Any]] = []
|
| 1076 |
+
width = max(4, min(int(window), 64))
|
| 1077 |
+
for idx in range(len(rows)):
|
| 1078 |
+
start = max(0, idx - width + 1)
|
| 1079 |
+
chunk = rows[start : idx + 1]
|
| 1080 |
+
points.append(
|
| 1081 |
+
{
|
| 1082 |
+
"observation": idx + 1,
|
| 1083 |
+
"rolling_mae_s": mean(float(row["absolute_error_s"]) for row in chunk),
|
| 1084 |
+
"rolling_action_agreement": sum(1 for row in chunk if row.get("action_match")) / len(chunk),
|
| 1085 |
+
"tool_kind": rows[idx].get("tool_kind", "generic"),
|
| 1086 |
+
"shifted_regime": bool(rows[idx].get("shifted_regime")),
|
| 1087 |
+
}
|
| 1088 |
+
)
|
| 1089 |
+
return points
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
def adaptive_tiering_study(
|
| 1093 |
+
config: dict[str, Any],
|
| 1094 |
+
*,
|
| 1095 |
+
horizon_s: float = 120.0,
|
| 1096 |
+
shift_fraction: float = 0.55,
|
| 1097 |
+
shift_multiplier: float = 2.5,
|
| 1098 |
+
alpha: float = 0.30,
|
| 1099 |
+
) -> dict[str, Any]:
|
| 1100 |
+
"""Compare fixed, adaptive, and oracle KV tiering on one non-stationary trace.
|
| 1101 |
+
|
| 1102 |
+
The adaptive candidates never inspect the realized future tool duration at
|
| 1103 |
+
decision time. They learn an EWMA online from completed tool calls. The
|
| 1104 |
+
oracle candidate intentionally sees the realized gap and serves only as an
|
| 1105 |
+
upper bound.
|
| 1106 |
+
"""
|
| 1107 |
+
base = AgentSessionConfig.from_dict(config)
|
| 1108 |
+
base.duration_s = max(float(horizon_s), 30.0)
|
| 1109 |
+
base.tool_regime_shift_fraction = min(max(float(shift_fraction), 0.05), 0.95)
|
| 1110 |
+
base.tool_regime_shift_multiplier = max(float(shift_multiplier), 1.0)
|
| 1111 |
+
base.adaptive_alpha = min(max(float(alpha), 0.01), 1.0)
|
| 1112 |
+
base.routing_policy = "bounded_affinity"
|
| 1113 |
+
trace = generate_agent_sessions(base)
|
| 1114 |
+
|
| 1115 |
+
candidates = [
|
| 1116 |
+
("Fixed TTL", "ttl", "per_tool_ema"),
|
| 1117 |
+
("Always host offload", "offload", "per_tool_ema"),
|
| 1118 |
+
("Adaptive global EWMA", "adaptive", "global_ema"),
|
| 1119 |
+
("Adaptive per-tool EWMA", "adaptive", "per_tool_ema"),
|
| 1120 |
+
("Oracle gap-aware", "gap_aware", "per_tool_ema"),
|
| 1121 |
+
]
|
| 1122 |
+
rows: list[dict[str, Any]] = []
|
| 1123 |
+
results: dict[str, dict[str, Any]] = {}
|
| 1124 |
+
for label, retention, scope in candidates:
|
| 1125 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 1126 |
+
cfg.retention_policy = retention
|
| 1127 |
+
cfg.adaptive_predictor_scope = scope
|
| 1128 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 1129 |
+
results[label] = result
|
| 1130 |
+
phase = _prediction_phase_metrics(result)
|
| 1131 |
+
rows.append(
|
| 1132 |
+
{
|
| 1133 |
+
"label": label,
|
| 1134 |
+
"retention": retention,
|
| 1135 |
+
"predictor_scope": scope if retention == "adaptive" else "not-active",
|
| 1136 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 1137 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 1138 |
+
"session_slo_attainment": result["summary"]["session_slo_attainment"],
|
| 1139 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 1140 |
+
"mean_hbm_gb": result["resource"]["mean_kv_gb"],
|
| 1141 |
+
"mean_host_gb": result["resource"]["mean_host_kv_gb"],
|
| 1142 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 1143 |
+
"prediction_count": result["resource"].get("adaptive_prediction_count", 0),
|
| 1144 |
+
"prediction_mae_s": result["resource"].get("adaptive_prediction_mae_s", 0.0),
|
| 1145 |
+
"oracle_action_agreement": result["resource"].get("adaptive_oracle_action_agreement", 0.0),
|
| 1146 |
+
**phase,
|
| 1147 |
+
}
|
| 1148 |
+
)
|
| 1149 |
+
|
| 1150 |
+
global_result = results["Adaptive global EWMA"]
|
| 1151 |
+
tool_result = results["Adaptive per-tool EWMA"]
|
| 1152 |
+
shift_observation = next(
|
| 1153 |
+
(
|
| 1154 |
+
idx + 1
|
| 1155 |
+
for idx, row in enumerate(tool_result.get("prediction", {}).get("rows", []))
|
| 1156 |
+
if row.get("shifted_regime")
|
| 1157 |
+
),
|
| 1158 |
+
0,
|
| 1159 |
+
)
|
| 1160 |
+
return {
|
| 1161 |
+
"protocol": "common-nonstationary-agent-program-trace",
|
| 1162 |
+
"study": "online-predictive-kv-tiering",
|
| 1163 |
+
"horizon_s": base.duration_s,
|
| 1164 |
+
"shift_fraction": base.tool_regime_shift_fraction,
|
| 1165 |
+
"shift_multiplier": base.tool_regime_shift_multiplier,
|
| 1166 |
+
"shift_time_s": base.duration_s * base.tool_regime_shift_fraction,
|
| 1167 |
+
"shift_observation": shift_observation,
|
| 1168 |
+
"threshold_s": base.gap_aware_threshold_s,
|
| 1169 |
+
"alpha": base.adaptive_alpha,
|
| 1170 |
+
"rows": rows,
|
| 1171 |
+
"learning_curves": {
|
| 1172 |
+
"global": _rolling_prediction_curve(global_result),
|
| 1173 |
+
"per_tool": _rolling_prediction_curve(tool_result),
|
| 1174 |
+
},
|
| 1175 |
+
"tool_profiles": TOOL_GAP_MULTIPLIERS,
|
| 1176 |
+
"note": (
|
| 1177 |
+
"The oracle gap-aware candidate sees realized future tool gaps and is an upper bound. "
|
| 1178 |
+
"Adaptive candidates learn only from tool calls that have already completed."
|
| 1179 |
+
),
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
|
| 1183 |
+
def adaptive_alpha_sweep(
|
| 1184 |
+
config: dict[str, Any],
|
| 1185 |
+
alpha_values: list[float] | None = None,
|
| 1186 |
+
*,
|
| 1187 |
+
horizon_s: float = 120.0,
|
| 1188 |
+
shift_fraction: float = 0.55,
|
| 1189 |
+
shift_multiplier: float = 2.5,
|
| 1190 |
+
) -> dict[str, Any]:
|
| 1191 |
+
"""Sweep EWMA adaptation speed on one shifted trace.
|
| 1192 |
+
|
| 1193 |
+
Lower alpha values are stable but adapt slowly; high values react quickly but
|
| 1194 |
+
are noisier. The study reports both pre- and post-shift prediction error and
|
| 1195 |
+
the resulting serving metrics.
|
| 1196 |
+
"""
|
| 1197 |
+
base = AgentSessionConfig.from_dict(config)
|
| 1198 |
+
base.duration_s = max(float(horizon_s), 30.0)
|
| 1199 |
+
base.tool_regime_shift_fraction = min(max(float(shift_fraction), 0.05), 0.95)
|
| 1200 |
+
base.tool_regime_shift_multiplier = max(float(shift_multiplier), 1.0)
|
| 1201 |
+
base.retention_policy = "adaptive"
|
| 1202 |
+
base.routing_policy = "bounded_affinity"
|
| 1203 |
+
base.adaptive_predictor_scope = "per_tool_ema"
|
| 1204 |
+
trace = generate_agent_sessions(base)
|
| 1205 |
+
values = alpha_values or [0.05, 0.10, 0.20, 0.30, 0.50, 0.75, 1.00]
|
| 1206 |
+
cleaned = sorted({min(max(float(value), 0.01), 1.0) for value in values})
|
| 1207 |
+
rows: list[dict[str, Any]] = []
|
| 1208 |
+
for alpha in cleaned:
|
| 1209 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 1210 |
+
cfg.adaptive_alpha = alpha
|
| 1211 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 1212 |
+
phase = _prediction_phase_metrics(result)
|
| 1213 |
+
rows.append(
|
| 1214 |
+
{
|
| 1215 |
+
"alpha": alpha,
|
| 1216 |
+
"prediction_mae_s": result["resource"]["adaptive_prediction_mae_s"],
|
| 1217 |
+
"oracle_action_agreement": result["resource"]["adaptive_oracle_action_agreement"],
|
| 1218 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 1219 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 1220 |
+
"session_slo_attainment": result["summary"]["session_slo_attainment"],
|
| 1221 |
+
"mean_hbm_gb": result["resource"]["mean_kv_gb"],
|
| 1222 |
+
"mean_host_gb": result["resource"]["mean_host_kv_gb"],
|
| 1223 |
+
**phase,
|
| 1224 |
+
}
|
| 1225 |
+
)
|
| 1226 |
+
best_post = min(rows, key=lambda row: (row["post_shift_mae_s"], row["p95_turn_ttft_ms"])) if rows else None
|
| 1227 |
+
return {
|
| 1228 |
+
"protocol": "common-nonstationary-agent-program-trace",
|
| 1229 |
+
"study": "adaptive-ewma-rate-sweep",
|
| 1230 |
+
"shift_fraction": base.tool_regime_shift_fraction,
|
| 1231 |
+
"shift_multiplier": base.tool_regime_shift_multiplier,
|
| 1232 |
+
"rows": rows,
|
| 1233 |
+
"best_post_shift_alpha": best_post["alpha"] if best_post else None,
|
| 1234 |
+
"note": "Alpha controls adaptation speed; this is an online heuristic study, not a learned model benchmark.",
|
| 1235 |
+
}
|
py/inferscale/api.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from .agentic import (
|
|
|
|
|
|
|
| 4 |
agent_affinity_sweep,
|
| 5 |
agent_memory_budget_sweep,
|
| 6 |
compare_agent_memory_policies,
|
|
@@ -25,6 +27,7 @@ def metadata() -> dict:
|
|
| 25 |
"agentic_modes": [
|
| 26 |
"session_simulation", "policy_compare", "ttl_sweep",
|
| 27 |
"memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
|
|
|
|
| 28 |
],
|
| 29 |
}
|
| 30 |
|
|
@@ -71,6 +74,22 @@ def execute(action: str, payload: dict) -> dict:
|
|
| 71 |
return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
|
| 72 |
if action == "agent_affinity_sweep":
|
| 73 |
return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
if action == "robustness_study":
|
| 75 |
config = payload.get("config", payload)
|
| 76 |
return robustness_study(
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from .agentic import (
|
| 4 |
+
adaptive_alpha_sweep,
|
| 5 |
+
adaptive_tiering_study,
|
| 6 |
agent_affinity_sweep,
|
| 7 |
agent_memory_budget_sweep,
|
| 8 |
compare_agent_memory_policies,
|
|
|
|
| 27 |
"agentic_modes": [
|
| 28 |
"session_simulation", "policy_compare", "ttl_sweep",
|
| 29 |
"memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
|
| 30 |
+
"predictive_tiering", "adaptive_alpha_sweep",
|
| 31 |
],
|
| 32 |
}
|
| 33 |
|
|
|
|
| 74 |
return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
|
| 75 |
if action == "agent_affinity_sweep":
|
| 76 |
return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
|
| 77 |
+
if action == "agent_predictive_tiering":
|
| 78 |
+
return adaptive_tiering_study(
|
| 79 |
+
payload.get("config", payload),
|
| 80 |
+
horizon_s=float(payload.get("horizon_s", 120.0)),
|
| 81 |
+
shift_fraction=float(payload.get("shift_fraction", 0.55)),
|
| 82 |
+
shift_multiplier=float(payload.get("shift_multiplier", 2.5)),
|
| 83 |
+
alpha=float(payload.get("alpha", 0.30)),
|
| 84 |
+
)
|
| 85 |
+
if action == "agent_adaptive_alpha_sweep":
|
| 86 |
+
return adaptive_alpha_sweep(
|
| 87 |
+
payload.get("config", payload),
|
| 88 |
+
payload.get("alpha_values"),
|
| 89 |
+
horizon_s=float(payload.get("horizon_s", 120.0)),
|
| 90 |
+
shift_fraction=float(payload.get("shift_fraction", 0.55)),
|
| 91 |
+
shift_multiplier=float(payload.get("shift_multiplier", 2.5)),
|
| 92 |
+
)
|
| 93 |
if action == "robustness_study":
|
| 94 |
config = payload.get("config", payload)
|
| 95 |
return robustness_study(
|
py/inferscale/prediction.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
PREDICTOR_SCOPES = {"global_ema", "per_tool_ema"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class _Estimate:
|
| 11 |
+
mean_s: float
|
| 12 |
+
count: int = 0
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class OnlineToolGapPredictor:
|
| 16 |
+
"""Tiny online tool-duration predictor used by adaptive KV tiering.
|
| 17 |
+
|
| 18 |
+
The predictor deliberately stays transparent: it keeps an exponentially
|
| 19 |
+
weighted moving average (EWMA) of observed tool-call durations. In
|
| 20 |
+
``per_tool_ema`` mode each tool family gets its own estimate, backed by a
|
| 21 |
+
global EWMA until enough observations have arrived. No future tool duration
|
| 22 |
+
is visible at decision time.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
*,
|
| 28 |
+
initial_mean_s: float,
|
| 29 |
+
alpha: float = 0.30,
|
| 30 |
+
min_observations: int = 2,
|
| 31 |
+
scope: str = "per_tool_ema",
|
| 32 |
+
) -> None:
|
| 33 |
+
if scope not in PREDICTOR_SCOPES:
|
| 34 |
+
raise ValueError(f"Unsupported predictor scope: {scope}")
|
| 35 |
+
self.scope = scope
|
| 36 |
+
self.alpha = min(max(float(alpha), 0.01), 1.0)
|
| 37 |
+
self.min_observations = max(int(min_observations), 1)
|
| 38 |
+
self.global_estimate = _Estimate(max(float(initial_mean_s), 0.0), 0)
|
| 39 |
+
self.per_tool: dict[str, _Estimate] = {}
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def _update(estimate: _Estimate, value_s: float, alpha: float) -> None:
|
| 43 |
+
value_s = max(float(value_s), 0.0)
|
| 44 |
+
if estimate.count == 0:
|
| 45 |
+
estimate.mean_s = value_s
|
| 46 |
+
else:
|
| 47 |
+
estimate.mean_s = alpha * value_s + (1.0 - alpha) * estimate.mean_s
|
| 48 |
+
estimate.count += 1
|
| 49 |
+
|
| 50 |
+
def predict(self, tool_kind: str) -> tuple[float, str, int]:
|
| 51 |
+
if self.scope == "per_tool_ema":
|
| 52 |
+
estimate = self.per_tool.get(tool_kind)
|
| 53 |
+
if estimate is not None and estimate.count >= self.min_observations:
|
| 54 |
+
return estimate.mean_s, "tool", estimate.count
|
| 55 |
+
return self.global_estimate.mean_s, "global", self.global_estimate.count
|
| 56 |
+
|
| 57 |
+
def observe(self, tool_kind: str, duration_s: float) -> None:
|
| 58 |
+
self._update(self.global_estimate, duration_s, self.alpha)
|
| 59 |
+
estimate = self.per_tool.setdefault(tool_kind, _Estimate(self.global_estimate.mean_s, 0))
|
| 60 |
+
self._update(estimate, duration_s, self.alpha)
|
| 61 |
+
|
| 62 |
+
def snapshot(self) -> dict:
|
| 63 |
+
return {
|
| 64 |
+
"scope": self.scope,
|
| 65 |
+
"alpha": self.alpha,
|
| 66 |
+
"min_observations": self.min_observations,
|
| 67 |
+
"global": {"mean_s": self.global_estimate.mean_s, "count": self.global_estimate.count},
|
| 68 |
+
"per_tool": {
|
| 69 |
+
key: {"mean_s": value.mean_s, "count": value.count}
|
| 70 |
+
for key, value in sorted(self.per_tool.items())
|
| 71 |
+
},
|
| 72 |
+
}
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "inferscale-sim"
|
| 7 |
-
version = "0.
|
| 8 |
description = "Interactive LLM serving simulator and SLO capacity planner"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "inferscale-sim"
|
| 7 |
+
version = "0.7.0"
|
| 8 |
description = "Interactive LLM serving simulator and SLO capacity planner"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
scripts/release_check.py
CHANGED
|
@@ -14,6 +14,8 @@ inferscale = importlib.import_module("inferscale")
|
|
| 14 |
internal_version = inferscale.__version__
|
| 15 |
design_space_search = inferscale.design_space_search
|
| 16 |
compare_agent_policies = inferscale.compare_agent_policies
|
|
|
|
|
|
|
| 17 |
compare_agent_memory_policies = inferscale.compare_agent_memory_policies
|
| 18 |
agent_memory_budget_sweep = inferscale.agent_memory_budget_sweep
|
| 19 |
agent_affinity_sweep = inferscale.agent_affinity_sweep
|
|
@@ -37,8 +39,8 @@ else:
|
|
| 37 |
|
| 38 |
if "sdk: static" not in README:
|
| 39 |
errors.append("README metadata must use sdk: static")
|
| 40 |
-
if internal_version != "0.
|
| 41 |
-
errors.append(f"internal package version is {internal_version}; expected 0.
|
| 42 |
|
| 43 |
# Public-facing release/version branding is intentionally absent. Model names
|
| 44 |
# such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
|
|
@@ -77,7 +79,7 @@ if "Download PNG" not in index or ".chart-download" not in app:
|
|
| 77 |
errors.append("chart PNG export controls are missing")
|
| 78 |
if "Worst repetition" not in index or "Target" not in index:
|
| 79 |
errors.append("capacity evidence columns are missing")
|
| 80 |
-
for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test selected hypothesis", "Agent Sessions", "Session Policy Arena", "TTL frontier", "Agent Memory Lab", "Affinity Frontier", "Stress HBM budget"]:
|
| 81 |
if expected not in index:
|
| 82 |
errors.append(f"UI is missing research/trace feature: {expected}")
|
| 83 |
|
|
@@ -197,6 +199,27 @@ try:
|
|
| 197 |
agent_affinity = agent_affinity_sweep(agent_cfg, [0, 150, 600])
|
| 198 |
if len(agent_affinity.get("rows", [])) != 3:
|
| 199 |
errors.append("bounded-affinity sweep is incomplete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
except Exception as exc: # pragma: no cover
|
| 201 |
errors.append(f"agent-session smoke test raised: {exc}")
|
| 202 |
|
|
@@ -232,5 +255,8 @@ print(f"Agent TTL candidates: {len(agent_ttl['rows'])}")
|
|
| 232 |
print(f"Agent memory policies: {agent_memory['candidate_count']}")
|
| 233 |
print(f"HBM budget study rows: {len(agent_budget['rows'])}")
|
| 234 |
print(f"Affinity sweep points: {len(agent_affinity['rows'])}")
|
|
|
|
|
|
|
|
|
|
| 235 |
print(f"Validation observations: {validation['observation_count']}")
|
| 236 |
print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
|
|
|
|
| 14 |
internal_version = inferscale.__version__
|
| 15 |
design_space_search = inferscale.design_space_search
|
| 16 |
compare_agent_policies = inferscale.compare_agent_policies
|
| 17 |
+
adaptive_tiering_study = inferscale.adaptive_tiering_study
|
| 18 |
+
adaptive_alpha_sweep = inferscale.adaptive_alpha_sweep
|
| 19 |
compare_agent_memory_policies = inferscale.compare_agent_memory_policies
|
| 20 |
agent_memory_budget_sweep = inferscale.agent_memory_budget_sweep
|
| 21 |
agent_affinity_sweep = inferscale.agent_affinity_sweep
|
|
|
|
| 39 |
|
| 40 |
if "sdk: static" not in README:
|
| 41 |
errors.append("README metadata must use sdk: static")
|
| 42 |
+
if internal_version != "0.7.0":
|
| 43 |
+
errors.append(f"internal package version is {internal_version}; expected 0.7.0")
|
| 44 |
|
| 45 |
# Public-facing release/version branding is intentionally absent. Model names
|
| 46 |
# such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
|
|
|
|
| 79 |
errors.append("chart PNG export controls are missing")
|
| 80 |
if "Worst repetition" not in index or "Target" not in index:
|
| 81 |
errors.append("capacity evidence columns are missing")
|
| 82 |
+
for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test selected hypothesis", "Agent Sessions", "Session Policy Arena", "TTL frontier", "Agent Memory Lab", "Affinity Frontier", "Stress HBM budget", "Predictive Tiering Lab", "Compare predictive policies", "Sweep adaptation rate"]:
|
| 83 |
if expected not in index:
|
| 84 |
errors.append(f"UI is missing research/trace feature: {expected}")
|
| 85 |
|
|
|
|
| 199 |
agent_affinity = agent_affinity_sweep(agent_cfg, [0, 150, 600])
|
| 200 |
if len(agent_affinity.get("rows", [])) != 3:
|
| 201 |
errors.append("bounded-affinity sweep is incomplete")
|
| 202 |
+
adaptive = run_agent_session_simulation(agent_cfg | {
|
| 203 |
+
"retention_policy": "adaptive",
|
| 204 |
+
"routing_policy": "bounded_affinity",
|
| 205 |
+
"adaptive_predictor_scope": "per_tool_ema",
|
| 206 |
+
})
|
| 207 |
+
if adaptive["provenance"].get("adaptive_policy") != "online-tool-gap-ewma-no-lookahead":
|
| 208 |
+
errors.append("adaptive policy provenance guard is missing")
|
| 209 |
+
if adaptive["resource"].get("adaptive_prediction_count", 0) <= 0:
|
| 210 |
+
errors.append("adaptive policy produced no tool-gap predictions")
|
| 211 |
+
predictive = adaptive_tiering_study(
|
| 212 |
+
agent_cfg | {"host_memory_gb": 4, "routing_policy": "bounded_affinity"},
|
| 213 |
+
horizon_s=40, shift_fraction=0.5, shift_multiplier=2.0, alpha=0.3,
|
| 214 |
+
)
|
| 215 |
+
if len(predictive.get("rows", [])) != 5 or predictive.get("shift_observation", 0) <= 0:
|
| 216 |
+
errors.append("predictive tiering study is incomplete")
|
| 217 |
+
alpha_sweep = adaptive_alpha_sweep(
|
| 218 |
+
agent_cfg | {"host_memory_gb": 4, "routing_policy": "bounded_affinity"},
|
| 219 |
+
[0.1, 0.3, 0.8], horizon_s=40, shift_fraction=0.5, shift_multiplier=2.0,
|
| 220 |
+
)
|
| 221 |
+
if len(alpha_sweep.get("rows", [])) != 3:
|
| 222 |
+
errors.append("adaptation-rate sweep is incomplete")
|
| 223 |
except Exception as exc: # pragma: no cover
|
| 224 |
errors.append(f"agent-session smoke test raised: {exc}")
|
| 225 |
|
|
|
|
| 255 |
print(f"Agent memory policies: {agent_memory['candidate_count']}")
|
| 256 |
print(f"HBM budget study rows: {len(agent_budget['rows'])}")
|
| 257 |
print(f"Affinity sweep points: {len(agent_affinity['rows'])}")
|
| 258 |
+
print(f"Adaptive predictions: {adaptive['resource']['adaptive_prediction_count']}")
|
| 259 |
+
print(f"Predictive-tiering candidates: {len(predictive['rows'])}")
|
| 260 |
+
print(f"Adaptation-rate points: {len(alpha_sweep['rows'])}")
|
| 261 |
print(f"Validation observations: {validation['observation_count']}")
|
| 262 |
print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
|
src/inferscale/__init__.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
from .agentic import (
|
|
|
|
|
|
|
| 2 |
agent_affinity_sweep,
|
| 3 |
agent_memory_budget_sweep,
|
| 4 |
compare_agent_memory_policies,
|
|
@@ -15,6 +17,8 @@ from .validation import validate_cases
|
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
"SimulationConfig",
|
|
|
|
|
|
|
| 18 |
"agent_affinity_sweep",
|
| 19 |
"agent_memory_budget_sweep",
|
| 20 |
"compare_agent_memory_policies",
|
|
@@ -35,4 +39,4 @@ __all__ = [
|
|
| 35 |
|
| 36 |
# Internal package metadata only; the public project intentionally avoids
|
| 37 |
# release/version branding in the interface and documentation.
|
| 38 |
-
__version__ = "0.
|
|
|
|
| 1 |
from .agentic import (
|
| 2 |
+
adaptive_alpha_sweep,
|
| 3 |
+
adaptive_tiering_study,
|
| 4 |
agent_affinity_sweep,
|
| 5 |
agent_memory_budget_sweep,
|
| 6 |
compare_agent_memory_policies,
|
|
|
|
| 17 |
|
| 18 |
__all__ = [
|
| 19 |
"SimulationConfig",
|
| 20 |
+
"adaptive_alpha_sweep",
|
| 21 |
+
"adaptive_tiering_study",
|
| 22 |
"agent_affinity_sweep",
|
| 23 |
"agent_memory_budget_sweep",
|
| 24 |
"compare_agent_memory_policies",
|
|
|
|
| 39 |
|
| 40 |
# Internal package metadata only; the public project intentionally avoids
|
| 41 |
# release/version branding in the interface and documentation.
|
| 42 |
+
__version__ = "0.7.0"
|
src/inferscale/agentic.py
CHANGED
|
@@ -9,11 +9,20 @@ from typing import Any
|
|
| 9 |
|
| 10 |
from .latency import AnalyticalLatencyModel
|
| 11 |
from .metrics import percentile
|
|
|
|
| 12 |
from .profiles import get_accelerator, get_model
|
| 13 |
|
| 14 |
|
| 15 |
ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
|
| 16 |
-
RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@dataclass
|
|
@@ -44,6 +53,11 @@ class AgentSessionConfig:
|
|
| 44 |
host_transfer_base_ms: float = 0.15
|
| 45 |
affinity_slack_ms: float = 150.0
|
| 46 |
gap_aware_threshold_s: float = 1.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
slo_turn_ttft_ms: float = 500.0
|
| 48 |
slo_session_e2e_ms: float = 30000.0
|
| 49 |
timeline_points: int = 240
|
|
@@ -67,6 +81,12 @@ class AgentSessionConfig:
|
|
| 67 |
cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
|
| 68 |
cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
|
| 69 |
cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
return cfg
|
| 71 |
|
| 72 |
def to_dict(self) -> dict[str, Any]:
|
|
@@ -80,6 +100,8 @@ class TurnSpec:
|
|
| 80 |
append_tokens: int
|
| 81 |
output_tokens: int
|
| 82 |
tool_gap_after_s: float
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
@dataclass
|
|
@@ -161,13 +183,23 @@ def generate_agent_sessions(cfg: AgentSessionConfig) -> list[SessionSpec]:
|
|
| 161 |
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 162 |
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 163 |
specs = []
|
|
|
|
|
|
|
| 164 |
for turn_idx in range(turns):
|
| 165 |
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 166 |
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 172 |
sid += 1
|
| 173 |
return sessions
|
|
@@ -224,6 +256,13 @@ class AgentSessionSimulator:
|
|
| 224 |
self.route_opportunities = 0
|
| 225 |
self.failed_turns = 0
|
| 226 |
self.tool_gap_total_s = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
for session in specs:
|
| 228 |
if session.turns:
|
| 229 |
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
|
@@ -406,7 +445,7 @@ class AgentSessionSimulator:
|
|
| 406 |
previous = replica.cache.get(session_id)
|
| 407 |
generation = (previous.generation + 1) if previous else 1
|
| 408 |
expiry: float | None = None
|
| 409 |
-
if keep and self.cfg.retention_policy
|
| 410 |
expiry = self.now + self.cfg.kv_ttl_s
|
| 411 |
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 412 |
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
|
@@ -542,6 +581,31 @@ class AgentSessionSimulator:
|
|
| 542 |
state_action = "offload_host_long_gap"
|
| 543 |
else:
|
| 544 |
state_action = "evict_host_full"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
e2e_ms = (self.now - ready_time) * 1000.0
|
| 547 |
self.turn_rows.append(
|
|
@@ -555,6 +619,9 @@ class AgentSessionSimulator:
|
|
| 555 |
"cache_source": cache_source,
|
| 556 |
"restore_ms": restore_s * 1000.0,
|
| 557 |
"state_action": state_action,
|
|
|
|
|
|
|
|
|
|
| 558 |
"prefill_tokens": prefill_tokens,
|
| 559 |
"context_tokens_after": projected_tokens,
|
| 560 |
"output_tokens": turn.output_tokens,
|
|
@@ -568,7 +635,9 @@ class AgentSessionSimulator:
|
|
| 568 |
runtime.completion_time = self.now
|
| 569 |
else:
|
| 570 |
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 571 |
-
self.
|
|
|
|
|
|
|
| 572 |
self._start_next(replica)
|
| 573 |
|
| 574 |
def run(self) -> dict[str, Any]:
|
|
@@ -577,7 +646,9 @@ class AgentSessionSimulator:
|
|
| 577 |
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 578 |
self._integrate_memory(event_time)
|
| 579 |
self.now = event_time
|
| 580 |
-
if kind == "
|
|
|
|
|
|
|
| 581 |
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 582 |
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 583 |
replica = self._route(session_id)
|
|
@@ -641,6 +712,8 @@ class AgentSessionSimulator:
|
|
| 641 |
}
|
| 642 |
host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
|
| 643 |
total_reuse_hits = self.cache_hits + self.host_cache_hits
|
|
|
|
|
|
|
| 644 |
resource = {
|
| 645 |
"replicas": self.cfg.replicas,
|
| 646 |
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
|
@@ -667,6 +740,13 @@ class AgentSessionSimulator:
|
|
| 667 |
"restored_gb": self.restore_bytes / 1e9,
|
| 668 |
"p95_host_transfer_ms": host_restore_p95_ms,
|
| 669 |
"tool_gap_total_s": self.tool_gap_total_s,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
}
|
| 671 |
return {
|
| 672 |
"config": self.cfg.to_dict(),
|
|
@@ -677,12 +757,19 @@ class AgentSessionSimulator:
|
|
| 677 |
"agent_service_model": "serial-per-replica-reference",
|
| 678 |
"host_tier_model": "serialized-reference-transfer",
|
| 679 |
"gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
|
|
|
|
|
|
|
|
|
|
| 680 |
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 681 |
},
|
| 682 |
"summary": summary,
|
| 683 |
"latency": latency,
|
| 684 |
"resource": resource,
|
| 685 |
"turns": successful_turns[:3000],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
"sessions": [
|
| 687 |
{
|
| 688 |
"session_id": s.spec.session_id,
|
|
@@ -957,3 +1044,192 @@ def agent_affinity_sweep(config: dict[str, Any], slack_values_ms: list[float] |
|
|
| 957 |
"study": "bounded-affinity-routing-frontier",
|
| 958 |
"rows": rows,
|
| 959 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from .latency import AnalyticalLatencyModel
|
| 11 |
from .metrics import percentile
|
| 12 |
+
from .prediction import OnlineToolGapPredictor, PREDICTOR_SCOPES
|
| 13 |
from .profiles import get_accelerator, get_model
|
| 14 |
|
| 15 |
|
| 16 |
ROUTING_POLICIES = {"least_load", "session_affinity", "bounded_affinity"}
|
| 17 |
+
RETENTION_POLICIES = {"evict", "retain", "ttl", "offload", "gap_aware", "adaptive"}
|
| 18 |
+
|
| 19 |
+
TOOL_GAP_MULTIPLIERS = {
|
| 20 |
+
"filesystem": 0.45,
|
| 21 |
+
"search": 0.80,
|
| 22 |
+
"database": 1.25,
|
| 23 |
+
"remote_api": 2.20,
|
| 24 |
+
}
|
| 25 |
+
TOOL_WEIGHTS = (0.28, 0.32, 0.22, 0.18)
|
| 26 |
|
| 27 |
|
| 28 |
@dataclass
|
|
|
|
| 53 |
host_transfer_base_ms: float = 0.15
|
| 54 |
affinity_slack_ms: float = 150.0
|
| 55 |
gap_aware_threshold_s: float = 1.5
|
| 56 |
+
adaptive_predictor_scope: str = "per_tool_ema"
|
| 57 |
+
adaptive_alpha: float = 0.30
|
| 58 |
+
adaptive_min_observations: int = 2
|
| 59 |
+
tool_regime_shift_fraction: float = 0.0
|
| 60 |
+
tool_regime_shift_multiplier: float = 1.0
|
| 61 |
slo_turn_ttft_ms: float = 500.0
|
| 62 |
slo_session_e2e_ms: float = 30000.0
|
| 63 |
timeline_points: int = 240
|
|
|
|
| 81 |
cfg.host_transfer_base_ms = max(float(cfg.host_transfer_base_ms), 0.0)
|
| 82 |
cfg.affinity_slack_ms = max(float(cfg.affinity_slack_ms), 0.0)
|
| 83 |
cfg.gap_aware_threshold_s = max(float(cfg.gap_aware_threshold_s), 0.0)
|
| 84 |
+
if cfg.adaptive_predictor_scope not in PREDICTOR_SCOPES:
|
| 85 |
+
raise ValueError(f"Unsupported adaptive predictor scope: {cfg.adaptive_predictor_scope}")
|
| 86 |
+
cfg.adaptive_alpha = min(max(float(cfg.adaptive_alpha), 0.01), 1.0)
|
| 87 |
+
cfg.adaptive_min_observations = max(1, int(cfg.adaptive_min_observations))
|
| 88 |
+
cfg.tool_regime_shift_fraction = min(max(float(cfg.tool_regime_shift_fraction), 0.0), 0.95)
|
| 89 |
+
cfg.tool_regime_shift_multiplier = max(float(cfg.tool_regime_shift_multiplier), 0.1)
|
| 90 |
return cfg
|
| 91 |
|
| 92 |
def to_dict(self) -> dict[str, Any]:
|
|
|
|
| 100 |
append_tokens: int
|
| 101 |
output_tokens: int
|
| 102 |
tool_gap_after_s: float
|
| 103 |
+
tool_kind: str = "generic"
|
| 104 |
+
shifted_regime: bool = False
|
| 105 |
|
| 106 |
|
| 107 |
@dataclass
|
|
|
|
| 183 |
turns = max(2, _sample_int(rng, cfg.turns_mean, cfg.turns_cv, 2))
|
| 184 |
initial = _sample_int(rng, cfg.initial_prompt_tokens_mean, cfg.token_cv, 32)
|
| 185 |
specs = []
|
| 186 |
+
tool_names = tuple(TOOL_GAP_MULTIPLIERS)
|
| 187 |
+
shifted = cfg.tool_regime_shift_fraction > 0.0 and now >= cfg.duration_s * cfg.tool_regime_shift_fraction
|
| 188 |
for turn_idx in range(turns):
|
| 189 |
append = 0 if turn_idx == 0 else _sample_int(rng, cfg.append_tokens_mean, cfg.token_cv, 8)
|
| 190 |
output = _sample_int(rng, cfg.output_tokens_mean, cfg.output_tokens_cv, 1)
|
| 191 |
+
tool_kind = rng.choices(tool_names, weights=TOOL_WEIGHTS, k=1)[0]
|
| 192 |
+
if turn_idx == turns - 1:
|
| 193 |
+
gap = 0.0
|
| 194 |
+
else:
|
| 195 |
+
mean_gap = cfg.tool_gap_mean_s * TOOL_GAP_MULTIPLIERS[tool_kind]
|
| 196 |
+
# The optional shift deliberately changes only the slower external
|
| 197 |
+
# tools. This creates a non-stationary workload where a global
|
| 198 |
+
# duration average is less informative than tool-aware history.
|
| 199 |
+
if shifted and tool_kind in {"database", "remote_api"}:
|
| 200 |
+
mean_gap *= cfg.tool_regime_shift_multiplier
|
| 201 |
+
gap = _sample_positive_lognormal(rng, mean_gap, cfg.tool_gap_cv, 0.0)
|
| 202 |
+
specs.append(TurnSpec(sid, turn_idx, append, output, gap, tool_kind, shifted))
|
| 203 |
sessions.append(SessionSpec(sid, now, initial, specs))
|
| 204 |
sid += 1
|
| 205 |
return sessions
|
|
|
|
| 256 |
self.route_opportunities = 0
|
| 257 |
self.failed_turns = 0
|
| 258 |
self.tool_gap_total_s = 0.0
|
| 259 |
+
self.tool_gap_predictor = OnlineToolGapPredictor(
|
| 260 |
+
initial_mean_s=cfg.tool_gap_mean_s,
|
| 261 |
+
alpha=cfg.adaptive_alpha,
|
| 262 |
+
min_observations=cfg.adaptive_min_observations,
|
| 263 |
+
scope=cfg.adaptive_predictor_scope,
|
| 264 |
+
)
|
| 265 |
+
self.prediction_rows: list[dict[str, Any]] = []
|
| 266 |
for session in specs:
|
| 267 |
if session.turns:
|
| 268 |
self._push(session.arrival_time, "turn_ready", session.session_id, 0)
|
|
|
|
| 445 |
previous = replica.cache.get(session_id)
|
| 446 |
generation = (previous.generation + 1) if previous else 1
|
| 447 |
expiry: float | None = None
|
| 448 |
+
if keep and self.cfg.retention_policy in {"ttl", "adaptive"}:
|
| 449 |
expiry = self.now + self.cfg.kv_ttl_s
|
| 450 |
replica.cache[session_id] = CacheEntry(session_id, tokens, size_gb, self.now, expiry, generation)
|
| 451 |
self.peak_kv_gb = max(self.peak_kv_gb, self._used_gb())
|
|
|
|
| 581 |
state_action = "offload_host_long_gap"
|
| 582 |
else:
|
| 583 |
state_action = "evict_host_full"
|
| 584 |
+
elif self.cfg.retention_policy == "adaptive":
|
| 585 |
+
predicted_gap_s, prediction_source, history_count = self.tool_gap_predictor.predict(turn.tool_kind)
|
| 586 |
+
predicted_retain = predicted_gap_s <= self.cfg.gap_aware_threshold_s
|
| 587 |
+
oracle_retain = turn.tool_gap_after_s <= self.cfg.gap_aware_threshold_s
|
| 588 |
+
if predicted_retain:
|
| 589 |
+
self._put_cache(replica, session_id, projected_tokens, keep=True)
|
| 590 |
+
state_action = "adaptive_retain_hbm"
|
| 591 |
+
elif self._offload_cache(replica, session_id, projected_tokens):
|
| 592 |
+
state_action = "adaptive_offload_host"
|
| 593 |
+
else:
|
| 594 |
+
state_action = "adaptive_evict_host_full"
|
| 595 |
+
self.prediction_rows.append({
|
| 596 |
+
"session_id": session_id,
|
| 597 |
+
"turn_index": turn_index + 1,
|
| 598 |
+
"tool_kind": turn.tool_kind,
|
| 599 |
+
"shifted_regime": turn.shifted_regime,
|
| 600 |
+
"predicted_gap_s": predicted_gap_s,
|
| 601 |
+
"actual_gap_s": turn.tool_gap_after_s,
|
| 602 |
+
"absolute_error_s": abs(predicted_gap_s - turn.tool_gap_after_s),
|
| 603 |
+
"prediction_source": prediction_source,
|
| 604 |
+
"history_count": history_count,
|
| 605 |
+
"predicted_action": "retain" if predicted_retain else "offload",
|
| 606 |
+
"oracle_action": "retain" if oracle_retain else "offload",
|
| 607 |
+
"action_match": predicted_retain == oracle_retain,
|
| 608 |
+
})
|
| 609 |
|
| 610 |
e2e_ms = (self.now - ready_time) * 1000.0
|
| 611 |
self.turn_rows.append(
|
|
|
|
| 619 |
"cache_source": cache_source,
|
| 620 |
"restore_ms": restore_s * 1000.0,
|
| 621 |
"state_action": state_action,
|
| 622 |
+
"tool_kind": turn.tool_kind,
|
| 623 |
+
"tool_gap_after_s": turn.tool_gap_after_s,
|
| 624 |
+
"shifted_regime": turn.shifted_regime,
|
| 625 |
"prefill_tokens": prefill_tokens,
|
| 626 |
"context_tokens_after": projected_tokens,
|
| 627 |
"output_tokens": turn.output_tokens,
|
|
|
|
| 635 |
runtime.completion_time = self.now
|
| 636 |
else:
|
| 637 |
self.tool_gap_total_s += turn.tool_gap_after_s
|
| 638 |
+
observed_at = self.now + turn.tool_gap_after_s
|
| 639 |
+
self._push(observed_at, "tool_observed", turn.tool_kind, turn.tool_gap_after_s)
|
| 640 |
+
self._push(observed_at, "turn_ready", session_id, turn_index + 1)
|
| 641 |
self._start_next(replica)
|
| 642 |
|
| 643 |
def run(self) -> dict[str, Any]:
|
|
|
|
| 646 |
event_time, _, kind, payload = heapq.heappop(self.events)
|
| 647 |
self._integrate_memory(event_time)
|
| 648 |
self.now = event_time
|
| 649 |
+
if kind == "tool_observed":
|
| 650 |
+
self.tool_gap_predictor.observe(str(payload[0]), float(payload[1]))
|
| 651 |
+
elif kind == "turn_ready":
|
| 652 |
session_id, turn_index = int(payload[0]), int(payload[1])
|
| 653 |
turn = self.sessions[session_id].spec.turns[turn_index]
|
| 654 |
replica = self._route(session_id)
|
|
|
|
| 712 |
}
|
| 713 |
host_restore_p95_ms = percentile(self.host_transfer_latencies_ms, 0.95)
|
| 714 |
total_reuse_hits = self.cache_hits + self.host_cache_hits
|
| 715 |
+
prediction_errors = [float(row["absolute_error_s"]) for row in self.prediction_rows]
|
| 716 |
+
prediction_matches = [bool(row["action_match"]) for row in self.prediction_rows]
|
| 717 |
resource = {
|
| 718 |
"replicas": self.cfg.replicas,
|
| 719 |
"kv_capacity_gb_per_replica": self.kv_capacity_gb,
|
|
|
|
| 740 |
"restored_gb": self.restore_bytes / 1e9,
|
| 741 |
"p95_host_transfer_ms": host_restore_p95_ms,
|
| 742 |
"tool_gap_total_s": self.tool_gap_total_s,
|
| 743 |
+
"adaptive_prediction_count": len(self.prediction_rows),
|
| 744 |
+
"adaptive_prediction_mae_s": mean(prediction_errors) if prediction_errors else 0.0,
|
| 745 |
+
"adaptive_prediction_p95_abs_error_s": percentile(prediction_errors, 0.95),
|
| 746 |
+
"adaptive_oracle_action_agreement": (
|
| 747 |
+
sum(1 for match in prediction_matches if match) / len(prediction_matches)
|
| 748 |
+
if prediction_matches else 0.0
|
| 749 |
+
),
|
| 750 |
}
|
| 751 |
return {
|
| 752 |
"config": self.cfg.to_dict(),
|
|
|
|
| 757 |
"agent_service_model": "serial-per-replica-reference",
|
| 758 |
"host_tier_model": "serialized-reference-transfer",
|
| 759 |
"gap_aware_policy": "oracle-upper-bound" if self.cfg.retention_policy == "gap_aware" else "not-active",
|
| 760 |
+
"adaptive_policy": (
|
| 761 |
+
"online-tool-gap-ewma-no-lookahead" if self.cfg.retention_policy == "adaptive" else "not-active"
|
| 762 |
+
),
|
| 763 |
"warning": "Agent-session mode isolates routing/KV-retention effects and does not model dynamic batching within each replica.",
|
| 764 |
},
|
| 765 |
"summary": summary,
|
| 766 |
"latency": latency,
|
| 767 |
"resource": resource,
|
| 768 |
"turns": successful_turns[:3000],
|
| 769 |
+
"prediction": {
|
| 770 |
+
"rows": self.prediction_rows[:3000],
|
| 771 |
+
"predictor": self.tool_gap_predictor.snapshot(),
|
| 772 |
+
},
|
| 773 |
"sessions": [
|
| 774 |
{
|
| 775 |
"session_id": s.spec.session_id,
|
|
|
|
| 1044 |
"study": "bounded-affinity-routing-frontier",
|
| 1045 |
"rows": rows,
|
| 1046 |
}
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
def _prediction_phase_metrics(result: dict[str, Any]) -> dict[str, float]:
|
| 1050 |
+
rows = result.get("prediction", {}).get("rows", [])
|
| 1051 |
+
before = [row for row in rows if not row.get("shifted_regime")]
|
| 1052 |
+
after = [row for row in rows if row.get("shifted_regime")]
|
| 1053 |
+
|
| 1054 |
+
def phase(items: list[dict[str, Any]]) -> tuple[float, float]:
|
| 1055 |
+
if not items:
|
| 1056 |
+
return 0.0, 0.0
|
| 1057 |
+
mae = mean(float(row["absolute_error_s"]) for row in items)
|
| 1058 |
+
agreement = sum(1 for row in items if row.get("action_match")) / len(items)
|
| 1059 |
+
return mae, agreement
|
| 1060 |
+
|
| 1061 |
+
pre_mae, pre_agreement = phase(before)
|
| 1062 |
+
post_mae, post_agreement = phase(after)
|
| 1063 |
+
return {
|
| 1064 |
+
"pre_shift_mae_s": pre_mae,
|
| 1065 |
+
"post_shift_mae_s": post_mae,
|
| 1066 |
+
"pre_shift_action_agreement": pre_agreement,
|
| 1067 |
+
"post_shift_action_agreement": post_agreement,
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def _rolling_prediction_curve(result: dict[str, Any], window: int = 16) -> list[dict[str, Any]]:
|
| 1072 |
+
rows = result.get("prediction", {}).get("rows", [])
|
| 1073 |
+
if not rows:
|
| 1074 |
+
return []
|
| 1075 |
+
points: list[dict[str, Any]] = []
|
| 1076 |
+
width = max(4, min(int(window), 64))
|
| 1077 |
+
for idx in range(len(rows)):
|
| 1078 |
+
start = max(0, idx - width + 1)
|
| 1079 |
+
chunk = rows[start : idx + 1]
|
| 1080 |
+
points.append(
|
| 1081 |
+
{
|
| 1082 |
+
"observation": idx + 1,
|
| 1083 |
+
"rolling_mae_s": mean(float(row["absolute_error_s"]) for row in chunk),
|
| 1084 |
+
"rolling_action_agreement": sum(1 for row in chunk if row.get("action_match")) / len(chunk),
|
| 1085 |
+
"tool_kind": rows[idx].get("tool_kind", "generic"),
|
| 1086 |
+
"shifted_regime": bool(rows[idx].get("shifted_regime")),
|
| 1087 |
+
}
|
| 1088 |
+
)
|
| 1089 |
+
return points
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
def adaptive_tiering_study(
|
| 1093 |
+
config: dict[str, Any],
|
| 1094 |
+
*,
|
| 1095 |
+
horizon_s: float = 120.0,
|
| 1096 |
+
shift_fraction: float = 0.55,
|
| 1097 |
+
shift_multiplier: float = 2.5,
|
| 1098 |
+
alpha: float = 0.30,
|
| 1099 |
+
) -> dict[str, Any]:
|
| 1100 |
+
"""Compare fixed, adaptive, and oracle KV tiering on one non-stationary trace.
|
| 1101 |
+
|
| 1102 |
+
The adaptive candidates never inspect the realized future tool duration at
|
| 1103 |
+
decision time. They learn an EWMA online from completed tool calls. The
|
| 1104 |
+
oracle candidate intentionally sees the realized gap and serves only as an
|
| 1105 |
+
upper bound.
|
| 1106 |
+
"""
|
| 1107 |
+
base = AgentSessionConfig.from_dict(config)
|
| 1108 |
+
base.duration_s = max(float(horizon_s), 30.0)
|
| 1109 |
+
base.tool_regime_shift_fraction = min(max(float(shift_fraction), 0.05), 0.95)
|
| 1110 |
+
base.tool_regime_shift_multiplier = max(float(shift_multiplier), 1.0)
|
| 1111 |
+
base.adaptive_alpha = min(max(float(alpha), 0.01), 1.0)
|
| 1112 |
+
base.routing_policy = "bounded_affinity"
|
| 1113 |
+
trace = generate_agent_sessions(base)
|
| 1114 |
+
|
| 1115 |
+
candidates = [
|
| 1116 |
+
("Fixed TTL", "ttl", "per_tool_ema"),
|
| 1117 |
+
("Always host offload", "offload", "per_tool_ema"),
|
| 1118 |
+
("Adaptive global EWMA", "adaptive", "global_ema"),
|
| 1119 |
+
("Adaptive per-tool EWMA", "adaptive", "per_tool_ema"),
|
| 1120 |
+
("Oracle gap-aware", "gap_aware", "per_tool_ema"),
|
| 1121 |
+
]
|
| 1122 |
+
rows: list[dict[str, Any]] = []
|
| 1123 |
+
results: dict[str, dict[str, Any]] = {}
|
| 1124 |
+
for label, retention, scope in candidates:
|
| 1125 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 1126 |
+
cfg.retention_policy = retention
|
| 1127 |
+
cfg.adaptive_predictor_scope = scope
|
| 1128 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 1129 |
+
results[label] = result
|
| 1130 |
+
phase = _prediction_phase_metrics(result)
|
| 1131 |
+
rows.append(
|
| 1132 |
+
{
|
| 1133 |
+
"label": label,
|
| 1134 |
+
"retention": retention,
|
| 1135 |
+
"predictor_scope": scope if retention == "adaptive" else "not-active",
|
| 1136 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 1137 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 1138 |
+
"session_slo_attainment": result["summary"]["session_slo_attainment"],
|
| 1139 |
+
"cache_hit_rate": result["resource"]["cross_turn_cache_hit_rate"],
|
| 1140 |
+
"mean_hbm_gb": result["resource"]["mean_kv_gb"],
|
| 1141 |
+
"mean_host_gb": result["resource"]["mean_host_kv_gb"],
|
| 1142 |
+
"recomputed_history_tokens": result["resource"]["recomputed_history_tokens"],
|
| 1143 |
+
"prediction_count": result["resource"].get("adaptive_prediction_count", 0),
|
| 1144 |
+
"prediction_mae_s": result["resource"].get("adaptive_prediction_mae_s", 0.0),
|
| 1145 |
+
"oracle_action_agreement": result["resource"].get("adaptive_oracle_action_agreement", 0.0),
|
| 1146 |
+
**phase,
|
| 1147 |
+
}
|
| 1148 |
+
)
|
| 1149 |
+
|
| 1150 |
+
global_result = results["Adaptive global EWMA"]
|
| 1151 |
+
tool_result = results["Adaptive per-tool EWMA"]
|
| 1152 |
+
shift_observation = next(
|
| 1153 |
+
(
|
| 1154 |
+
idx + 1
|
| 1155 |
+
for idx, row in enumerate(tool_result.get("prediction", {}).get("rows", []))
|
| 1156 |
+
if row.get("shifted_regime")
|
| 1157 |
+
),
|
| 1158 |
+
0,
|
| 1159 |
+
)
|
| 1160 |
+
return {
|
| 1161 |
+
"protocol": "common-nonstationary-agent-program-trace",
|
| 1162 |
+
"study": "online-predictive-kv-tiering",
|
| 1163 |
+
"horizon_s": base.duration_s,
|
| 1164 |
+
"shift_fraction": base.tool_regime_shift_fraction,
|
| 1165 |
+
"shift_multiplier": base.tool_regime_shift_multiplier,
|
| 1166 |
+
"shift_time_s": base.duration_s * base.tool_regime_shift_fraction,
|
| 1167 |
+
"shift_observation": shift_observation,
|
| 1168 |
+
"threshold_s": base.gap_aware_threshold_s,
|
| 1169 |
+
"alpha": base.adaptive_alpha,
|
| 1170 |
+
"rows": rows,
|
| 1171 |
+
"learning_curves": {
|
| 1172 |
+
"global": _rolling_prediction_curve(global_result),
|
| 1173 |
+
"per_tool": _rolling_prediction_curve(tool_result),
|
| 1174 |
+
},
|
| 1175 |
+
"tool_profiles": TOOL_GAP_MULTIPLIERS,
|
| 1176 |
+
"note": (
|
| 1177 |
+
"The oracle gap-aware candidate sees realized future tool gaps and is an upper bound. "
|
| 1178 |
+
"Adaptive candidates learn only from tool calls that have already completed."
|
| 1179 |
+
),
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
|
| 1183 |
+
def adaptive_alpha_sweep(
|
| 1184 |
+
config: dict[str, Any],
|
| 1185 |
+
alpha_values: list[float] | None = None,
|
| 1186 |
+
*,
|
| 1187 |
+
horizon_s: float = 120.0,
|
| 1188 |
+
shift_fraction: float = 0.55,
|
| 1189 |
+
shift_multiplier: float = 2.5,
|
| 1190 |
+
) -> dict[str, Any]:
|
| 1191 |
+
"""Sweep EWMA adaptation speed on one shifted trace.
|
| 1192 |
+
|
| 1193 |
+
Lower alpha values are stable but adapt slowly; high values react quickly but
|
| 1194 |
+
are noisier. The study reports both pre- and post-shift prediction error and
|
| 1195 |
+
the resulting serving metrics.
|
| 1196 |
+
"""
|
| 1197 |
+
base = AgentSessionConfig.from_dict(config)
|
| 1198 |
+
base.duration_s = max(float(horizon_s), 30.0)
|
| 1199 |
+
base.tool_regime_shift_fraction = min(max(float(shift_fraction), 0.05), 0.95)
|
| 1200 |
+
base.tool_regime_shift_multiplier = max(float(shift_multiplier), 1.0)
|
| 1201 |
+
base.retention_policy = "adaptive"
|
| 1202 |
+
base.routing_policy = "bounded_affinity"
|
| 1203 |
+
base.adaptive_predictor_scope = "per_tool_ema"
|
| 1204 |
+
trace = generate_agent_sessions(base)
|
| 1205 |
+
values = alpha_values or [0.05, 0.10, 0.20, 0.30, 0.50, 0.75, 1.00]
|
| 1206 |
+
cleaned = sorted({min(max(float(value), 0.01), 1.0) for value in values})
|
| 1207 |
+
rows: list[dict[str, Any]] = []
|
| 1208 |
+
for alpha in cleaned:
|
| 1209 |
+
cfg = AgentSessionConfig.from_dict(base.to_dict())
|
| 1210 |
+
cfg.adaptive_alpha = alpha
|
| 1211 |
+
result = run_agent_session_simulation(cfg.to_dict(), trace)
|
| 1212 |
+
phase = _prediction_phase_metrics(result)
|
| 1213 |
+
rows.append(
|
| 1214 |
+
{
|
| 1215 |
+
"alpha": alpha,
|
| 1216 |
+
"prediction_mae_s": result["resource"]["adaptive_prediction_mae_s"],
|
| 1217 |
+
"oracle_action_agreement": result["resource"]["adaptive_oracle_action_agreement"],
|
| 1218 |
+
"p95_turn_ttft_ms": result["latency"]["turn_ttft_ms"]["p95"],
|
| 1219 |
+
"p95_session_e2e_ms": result["latency"]["session_e2e_ms"]["p95"],
|
| 1220 |
+
"session_slo_attainment": result["summary"]["session_slo_attainment"],
|
| 1221 |
+
"mean_hbm_gb": result["resource"]["mean_kv_gb"],
|
| 1222 |
+
"mean_host_gb": result["resource"]["mean_host_kv_gb"],
|
| 1223 |
+
**phase,
|
| 1224 |
+
}
|
| 1225 |
+
)
|
| 1226 |
+
best_post = min(rows, key=lambda row: (row["post_shift_mae_s"], row["p95_turn_ttft_ms"])) if rows else None
|
| 1227 |
+
return {
|
| 1228 |
+
"protocol": "common-nonstationary-agent-program-trace",
|
| 1229 |
+
"study": "adaptive-ewma-rate-sweep",
|
| 1230 |
+
"shift_fraction": base.tool_regime_shift_fraction,
|
| 1231 |
+
"shift_multiplier": base.tool_regime_shift_multiplier,
|
| 1232 |
+
"rows": rows,
|
| 1233 |
+
"best_post_shift_alpha": best_post["alpha"] if best_post else None,
|
| 1234 |
+
"note": "Alpha controls adaptation speed; this is an online heuristic study, not a learned model benchmark.",
|
| 1235 |
+
}
|
src/inferscale/api.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from .agentic import (
|
|
|
|
|
|
|
| 4 |
agent_affinity_sweep,
|
| 5 |
agent_memory_budget_sweep,
|
| 6 |
compare_agent_memory_policies,
|
|
@@ -25,6 +27,7 @@ def metadata() -> dict:
|
|
| 25 |
"agentic_modes": [
|
| 26 |
"session_simulation", "policy_compare", "ttl_sweep",
|
| 27 |
"memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
|
|
|
|
| 28 |
],
|
| 29 |
}
|
| 30 |
|
|
@@ -71,6 +74,22 @@ def execute(action: str, payload: dict) -> dict:
|
|
| 71 |
return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
|
| 72 |
if action == "agent_affinity_sweep":
|
| 73 |
return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
if action == "robustness_study":
|
| 75 |
config = payload.get("config", payload)
|
| 76 |
return robustness_study(
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from .agentic import (
|
| 4 |
+
adaptive_alpha_sweep,
|
| 5 |
+
adaptive_tiering_study,
|
| 6 |
agent_affinity_sweep,
|
| 7 |
agent_memory_budget_sweep,
|
| 8 |
compare_agent_memory_policies,
|
|
|
|
| 27 |
"agentic_modes": [
|
| 28 |
"session_simulation", "policy_compare", "ttl_sweep",
|
| 29 |
"memory_policy_compare", "memory_budget_sweep", "affinity_sweep",
|
| 30 |
+
"predictive_tiering", "adaptive_alpha_sweep",
|
| 31 |
],
|
| 32 |
}
|
| 33 |
|
|
|
|
| 74 |
return agent_memory_budget_sweep(payload.get("config", payload), payload.get("budget_multipliers"))
|
| 75 |
if action == "agent_affinity_sweep":
|
| 76 |
return agent_affinity_sweep(payload.get("config", payload), payload.get("slack_values_ms"))
|
| 77 |
+
if action == "agent_predictive_tiering":
|
| 78 |
+
return adaptive_tiering_study(
|
| 79 |
+
payload.get("config", payload),
|
| 80 |
+
horizon_s=float(payload.get("horizon_s", 120.0)),
|
| 81 |
+
shift_fraction=float(payload.get("shift_fraction", 0.55)),
|
| 82 |
+
shift_multiplier=float(payload.get("shift_multiplier", 2.5)),
|
| 83 |
+
alpha=float(payload.get("alpha", 0.30)),
|
| 84 |
+
)
|
| 85 |
+
if action == "agent_adaptive_alpha_sweep":
|
| 86 |
+
return adaptive_alpha_sweep(
|
| 87 |
+
payload.get("config", payload),
|
| 88 |
+
payload.get("alpha_values"),
|
| 89 |
+
horizon_s=float(payload.get("horizon_s", 120.0)),
|
| 90 |
+
shift_fraction=float(payload.get("shift_fraction", 0.55)),
|
| 91 |
+
shift_multiplier=float(payload.get("shift_multiplier", 2.5)),
|
| 92 |
+
)
|
| 93 |
if action == "robustness_study":
|
| 94 |
config = payload.get("config", payload)
|
| 95 |
return robustness_study(
|
src/inferscale/prediction.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
PREDICTOR_SCOPES = {"global_ema", "per_tool_ema"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class _Estimate:
|
| 11 |
+
mean_s: float
|
| 12 |
+
count: int = 0
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class OnlineToolGapPredictor:
|
| 16 |
+
"""Tiny online tool-duration predictor used by adaptive KV tiering.
|
| 17 |
+
|
| 18 |
+
The predictor deliberately stays transparent: it keeps an exponentially
|
| 19 |
+
weighted moving average (EWMA) of observed tool-call durations. In
|
| 20 |
+
``per_tool_ema`` mode each tool family gets its own estimate, backed by a
|
| 21 |
+
global EWMA until enough observations have arrived. No future tool duration
|
| 22 |
+
is visible at decision time.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
*,
|
| 28 |
+
initial_mean_s: float,
|
| 29 |
+
alpha: float = 0.30,
|
| 30 |
+
min_observations: int = 2,
|
| 31 |
+
scope: str = "per_tool_ema",
|
| 32 |
+
) -> None:
|
| 33 |
+
if scope not in PREDICTOR_SCOPES:
|
| 34 |
+
raise ValueError(f"Unsupported predictor scope: {scope}")
|
| 35 |
+
self.scope = scope
|
| 36 |
+
self.alpha = min(max(float(alpha), 0.01), 1.0)
|
| 37 |
+
self.min_observations = max(int(min_observations), 1)
|
| 38 |
+
self.global_estimate = _Estimate(max(float(initial_mean_s), 0.0), 0)
|
| 39 |
+
self.per_tool: dict[str, _Estimate] = {}
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def _update(estimate: _Estimate, value_s: float, alpha: float) -> None:
|
| 43 |
+
value_s = max(float(value_s), 0.0)
|
| 44 |
+
if estimate.count == 0:
|
| 45 |
+
estimate.mean_s = value_s
|
| 46 |
+
else:
|
| 47 |
+
estimate.mean_s = alpha * value_s + (1.0 - alpha) * estimate.mean_s
|
| 48 |
+
estimate.count += 1
|
| 49 |
+
|
| 50 |
+
def predict(self, tool_kind: str) -> tuple[float, str, int]:
|
| 51 |
+
if self.scope == "per_tool_ema":
|
| 52 |
+
estimate = self.per_tool.get(tool_kind)
|
| 53 |
+
if estimate is not None and estimate.count >= self.min_observations:
|
| 54 |
+
return estimate.mean_s, "tool", estimate.count
|
| 55 |
+
return self.global_estimate.mean_s, "global", self.global_estimate.count
|
| 56 |
+
|
| 57 |
+
def observe(self, tool_kind: str, duration_s: float) -> None:
|
| 58 |
+
self._update(self.global_estimate, duration_s, self.alpha)
|
| 59 |
+
estimate = self.per_tool.setdefault(tool_kind, _Estimate(self.global_estimate.mean_s, 0))
|
| 60 |
+
self._update(estimate, duration_s, self.alpha)
|
| 61 |
+
|
| 62 |
+
def snapshot(self) -> dict:
|
| 63 |
+
return {
|
| 64 |
+
"scope": self.scope,
|
| 65 |
+
"alpha": self.alpha,
|
| 66 |
+
"min_observations": self.min_observations,
|
| 67 |
+
"global": {"mean_s": self.global_estimate.mean_s, "count": self.global_estimate.count},
|
| 68 |
+
"per_tool": {
|
| 69 |
+
key: {"mean_s": value.mean_s, "count": value.count}
|
| 70 |
+
for key, value in sorted(self.per_tool.items())
|
| 71 |
+
},
|
| 72 |
+
}
|
styles.css
CHANGED
|
@@ -170,6 +170,7 @@ select:focus, input[type="number"]:focus { border-color: #5475a8; box-shadow: 0
|
|
| 170 |
.unit { position: absolute; right: 9px; bottom: 12px; color: #697687; font-size: 10.5px; pointer-events: none; }
|
| 171 |
.field-grid { display: grid; gap: 10px; margin-bottom: 10px; }
|
| 172 |
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
|
|
|
| 173 |
.subcontrols { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--line-soft); background: #0a0f15; }
|
| 174 |
.subcontrols .section-kicker { margin-bottom: 10px; }
|
| 175 |
hr { border: 0; border-top: 1px solid var(--line-soft); margin: 17px 0; }
|
|
@@ -325,6 +326,9 @@ input[type="file"]::file-selector-button {
|
|
| 325 |
.metric-grid.eight-agent { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
| 326 |
.stacked-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
| 327 |
.stacked-actions .compact { min-width: 168px; }
|
|
|
|
|
|
|
| 328 |
@media (max-width: 1250px) { .metric-grid.eight-agent { grid-template-columns: repeat(2, 1fr); } }
|
| 329 |
-
@media (max-width:
|
|
|
|
| 330 |
.experiment-subsection { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line); }
|
|
|
|
| 170 |
.unit { position: absolute; right: 9px; bottom: 12px; color: #697687; font-size: 10.5px; pointer-events: none; }
|
| 171 |
.field-grid { display: grid; gap: 10px; margin-bottom: 10px; }
|
| 172 |
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
| 173 |
+
.field-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
| 174 |
.subcontrols { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--line-soft); background: #0a0f15; }
|
| 175 |
.subcontrols .section-kicker { margin-bottom: 10px; }
|
| 176 |
hr { border: 0; border-top: 1px solid var(--line-soft); margin: 17px 0; }
|
|
|
|
| 326 |
.metric-grid.eight-agent { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
| 327 |
.stacked-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
| 328 |
.stacked-actions .compact { min-width: 168px; }
|
| 329 |
+
.horizontal-actions { margin: 10px 0 14px; justify-content: flex-start; }
|
| 330 |
+
.predictive-controls { margin-top: 12px; }
|
| 331 |
@media (max-width: 1250px) { .metric-grid.eight-agent { grid-template-columns: repeat(2, 1fr); } }
|
| 332 |
+
@media (max-width: 960px) { .field-grid.four { grid-template-columns: 1fr 1fr; } }
|
| 333 |
+
@media (max-width: 720px) { .metric-grid.eight-agent { grid-template-columns: 1fr 1fr; } .field-grid.four { grid-template-columns: 1fr; } .stacked-actions { width: 100%; } .stacked-actions .compact { width: 100% !important; } }
|
| 334 |
.experiment-subsection { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line); }
|
tests/test_prediction.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from inferscale.agentic import adaptive_alpha_sweep, adaptive_tiering_study, run_agent_session_simulation
|
| 2 |
+
from inferscale.prediction import OnlineToolGapPredictor
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _agent_cfg():
|
| 6 |
+
return {
|
| 7 |
+
"model": "Qwen2.5-3B",
|
| 8 |
+
"accelerator": "L4",
|
| 9 |
+
"quantization": "int8",
|
| 10 |
+
"duration_s": 24,
|
| 11 |
+
"session_rate_rps": 0.3,
|
| 12 |
+
"replicas": 2,
|
| 13 |
+
"seed": 7,
|
| 14 |
+
"retention_policy": "adaptive",
|
| 15 |
+
"routing_policy": "bounded_affinity",
|
| 16 |
+
"host_memory_gb": 4,
|
| 17 |
+
"gap_aware_threshold_s": 1.5,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_online_predictor_uses_completed_history_only():
|
| 22 |
+
predictor = OnlineToolGapPredictor(initial_mean_s=1.5, alpha=0.5, min_observations=2, scope="per_tool_ema")
|
| 23 |
+
first, source, count = predictor.predict("search")
|
| 24 |
+
assert first == 1.5
|
| 25 |
+
assert source == "global"
|
| 26 |
+
assert count == 0
|
| 27 |
+
|
| 28 |
+
predictor.observe("search", 0.4)
|
| 29 |
+
second, source, count = predictor.predict("search")
|
| 30 |
+
assert source == "global" # tool estimate is still warming up
|
| 31 |
+
assert count == 1
|
| 32 |
+
|
| 33 |
+
predictor.observe("search", 0.6)
|
| 34 |
+
third, source, count = predictor.predict("search")
|
| 35 |
+
assert source == "tool"
|
| 36 |
+
assert count == 2
|
| 37 |
+
assert 0.4 <= third <= 0.6
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_adaptive_agent_run_reports_prediction_provenance():
|
| 41 |
+
result = run_agent_session_simulation(_agent_cfg())
|
| 42 |
+
assert result["provenance"]["adaptive_policy"] == "online-tool-gap-ewma-no-lookahead"
|
| 43 |
+
assert result["resource"]["adaptive_prediction_count"] > 0
|
| 44 |
+
assert result["resource"]["adaptive_prediction_mae_s"] >= 0
|
| 45 |
+
assert 0 <= result["resource"]["adaptive_oracle_action_agreement"] <= 1
|
| 46 |
+
assert result["prediction"]["rows"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_predictive_tiering_study_has_common_shifted_trace():
|
| 50 |
+
result = adaptive_tiering_study(
|
| 51 |
+
_agent_cfg(), horizon_s=60, shift_fraction=0.5, shift_multiplier=2.0, alpha=0.3
|
| 52 |
+
)
|
| 53 |
+
assert result["protocol"] == "common-nonstationary-agent-program-trace"
|
| 54 |
+
assert len(result["rows"]) == 5
|
| 55 |
+
assert result["shift_observation"] > 0
|
| 56 |
+
labels = {row["label"] for row in result["rows"]}
|
| 57 |
+
assert "Adaptive per-tool EWMA" in labels
|
| 58 |
+
assert "Oracle gap-aware" in labels
|
| 59 |
+
adaptive = next(row for row in result["rows"] if row["label"] == "Adaptive per-tool EWMA")
|
| 60 |
+
assert adaptive["prediction_count"] > 0
|
| 61 |
+
assert result["learning_curves"]["per_tool"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_adaptation_rate_sweep_uses_same_trace():
|
| 65 |
+
result = adaptive_alpha_sweep(
|
| 66 |
+
_agent_cfg(), [0.1, 0.3, 0.8], horizon_s=60, shift_fraction=0.5, shift_multiplier=2.0
|
| 67 |
+
)
|
| 68 |
+
assert len(result["rows"]) == 3
|
| 69 |
+
assert result["best_post_shift_alpha"] in {0.1, 0.3, 0.8}
|
| 70 |
+
for row in result["rows"]:
|
| 71 |
+
assert row["pre_shift_mae_s"] >= 0
|
| 72 |
+
assert row["post_shift_mae_s"] >= 0
|
| 73 |
+
assert 0 <= row["oracle_action_agreement"] <= 1
|
worker.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const MODULES = [
|
|
| 11 |
"metrics.py",
|
| 12 |
"models.py",
|
| 13 |
"optimizer.py",
|
|
|
|
| 14 |
"profiles.py",
|
| 15 |
"research.py",
|
| 16 |
"simulator.py",
|
|
|
|
| 11 |
"metrics.py",
|
| 12 |
"models.py",
|
| 13 |
"optimizer.py",
|
| 14 |
+
"prediction.py",
|
| 15 |
"profiles.py",
|
| 16 |
"research.py",
|
| 17 |
"simulator.py",
|