ArchitSharma commited on
Commit
d2258e5
·
1 Parent(s): 94910ac

Add online predictive KV tiering experiments

Browse files
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
- Six live experiments are included:
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 hostTier = retention === "offload" || retention === "gap_aware";
992
- const gapAware = retention === "gap_aware";
 
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.6.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
- gap = 0.0 if turn_idx == turns - 1 else _sample_positive_lognormal(
168
- rng, cfg.tool_gap_mean_s, cfg.tool_gap_cv, 0.0
169
- )
170
- specs.append(TurnSpec(sid, turn_idx, append, output, gap))
 
 
 
 
 
 
 
 
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 == "ttl":
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._push(self.now + turn.tool_gap_after_s, "turn_ready", session_id, turn_index + 1)
 
 
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 == "turn_ready":
 
 
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.6.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.6.0":
41
- errors.append(f"internal package version is {internal_version}; expected 0.6.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.6.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
- gap = 0.0 if turn_idx == turns - 1 else _sample_positive_lognormal(
168
- rng, cfg.tool_gap_mean_s, cfg.tool_gap_cv, 0.0
169
- )
170
- specs.append(TurnSpec(sid, turn_idx, append, output, gap))
 
 
 
 
 
 
 
 
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 == "ttl":
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._push(self.now + turn.tool_gap_after_s, "turn_ready", session_id, turn_index + 1)
 
 
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 == "turn_ready":
 
 
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: 720px) { .metric-grid.eight-agent { grid-template-columns: 1fr 1fr; } .stacked-actions { width: 100%; } .stacked-actions .compact { width: 100% !important; } }
 
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",