From 22f4627f6bdc0427aba6bcbb8e56ec10c046441e Mon Sep 17 00:00:00 2001 From: Teng Ma Date: Tue, 9 Jun 2026 22:30:39 +0800 Subject: [PATCH] feat: add parametric synthetic trace generator for Python CLI and web calculator Adds a synthetic trace generator that creates JSONL-compatible trace records modeling multi-session, multi-turn conversations with configurable prefix sharing. Supports both the Python CLI (`generate-trace` subcommand) and an interactive card in the web calculator with Generate & Analyze, Download JSONL, and Apply to Calculator functionality. Co-Authored-By: Claude Opus 4.6 --- src/kvcache_upper_bound/cli/main.py | 67 ++++ src/kvcache_upper_bound/synthetic/__init__.py | 5 + .../synthetic/generator.py | 119 +++++++ tests/test_cli_generate_trace.py | 158 +++++++++ tests/test_synthetic_generator.py | 221 +++++++++++++ website/calculator.html | 308 +++++++++++++++++- 6 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 src/kvcache_upper_bound/synthetic/__init__.py create mode 100644 src/kvcache_upper_bound/synthetic/generator.py create mode 100644 tests/test_cli_generate_trace.py create mode 100644 tests/test_synthetic_generator.py diff --git a/src/kvcache_upper_bound/cli/main.py b/src/kvcache_upper_bound/cli/main.py index 73128ba..c68826e 100644 --- a/src/kvcache_upper_bound/cli/main.py +++ b/src/kvcache_upper_bound/cli/main.py @@ -38,6 +38,7 @@ load_bucket_analysis_config, write_bucket_outputs, ) +from kvcache_upper_bound.synthetic import SyntheticTraceConfig, generate_synthetic_trace from kvcache_upper_bound.verification import ( build_bucket_audit_report, write_bucket_audit_outputs, @@ -185,6 +186,38 @@ def main() -> int: help="Allow replay-only synthetic hash_ids when benchmark records do not provide them", ) + generate_trace_parser = subparsers.add_parser( + "generate-trace", + help="Generate a parametric synthetic trace for KVCache analysis", + ) + generate_trace_parser.add_argument( + "--sessions", type=int, required=True, help="Number of sessions" + ) + generate_trace_parser.add_argument( + "--turns", type=int, required=True, help="Turns per session" + ) + generate_trace_parser.add_argument( + "--shared-prefix-blocks", type=int, required=True, + help="Number of shared prefix blocks", + ) + generate_trace_parser.add_argument( + "--new-blocks-per-turn", type=int, required=True, + help="Average new blocks per turn", + ) + generate_trace_parser.add_argument( + "--block-size", type=int, default=16, help="Tokens per block (default: 16)" + ) + generate_trace_parser.add_argument( + "--prefix-diversity", type=float, default=0.3, + help="Prefix diversity 0.0-1.0 (default: 0.3)", + ) + generate_trace_parser.add_argument( + "--seed", type=int, default=None, help="Random seed for reproducibility" + ) + generate_trace_parser.add_argument( + "--output", required=True, help="Output JSONL file path" + ) + args = parser.parse_args() if args.command == "list-datasets": return _run_list_datasets() @@ -200,6 +233,8 @@ def main() -> int: return _run_convert_conversation_dataset(args) if args.command == "convert-benchmark-results": return _run_convert_benchmark_results(args) + if args.command == "generate-trace": + return _run_generate_trace(args) raise ValueError(f"unsupported command: {args.command}") @@ -487,6 +522,38 @@ def _run_convert_benchmark_results(args: argparse.Namespace) -> int: return 0 +def _run_generate_trace(args: argparse.Namespace) -> int: + config = SyntheticTraceConfig( + num_sessions=args.sessions, + turns_per_session=args.turns, + shared_prefix_blocks=args.shared_prefix_blocks, + avg_new_blocks_per_turn=args.new_blocks_per_turn, + block_size=args.block_size, + prefix_diversity=args.prefix_diversity, + seed=args.seed, + ) + records = generate_synthetic_trace(config) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + payload = { + "mode": "generate_trace", + "output": str(output_path.resolve()), + "num_sessions": config.num_sessions, + "turns_per_session": config.turns_per_session, + "shared_prefix_blocks": config.shared_prefix_blocks, + "avg_new_blocks_per_turn": config.avg_new_blocks_per_turn, + "block_size": config.block_size, + "prefix_diversity": config.prefix_diversity, + "seed": config.seed, + "total_records": len(records), + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + def _build_analysis_metadata_payload( trace: str, config_path: str, diff --git a/src/kvcache_upper_bound/synthetic/__init__.py b/src/kvcache_upper_bound/synthetic/__init__.py new file mode 100644 index 0000000..68198e2 --- /dev/null +++ b/src/kvcache_upper_bound/synthetic/__init__.py @@ -0,0 +1,5 @@ +"""Parametric synthetic trace generator for KVCache analysis.""" + +from .generator import SyntheticTraceConfig, generate_synthetic_trace + +__all__ = ["SyntheticTraceConfig", "generate_synthetic_trace"] diff --git a/src/kvcache_upper_bound/synthetic/generator.py b/src/kvcache_upper_bound/synthetic/generator.py new file mode 100644 index 0000000..eff99a8 --- /dev/null +++ b/src/kvcache_upper_bound/synthetic/generator.py @@ -0,0 +1,119 @@ +"""Parametric synthetic trace generator. + +Generates JSONL-compatible trace records that model multi-session, +multi-turn conversations with configurable prefix sharing. +""" + +from __future__ import annotations + +import hashlib +import math +import random +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class SyntheticTraceConfig: + """Configuration for synthetic trace generation.""" + + num_sessions: int + turns_per_session: int + shared_prefix_blocks: int + avg_new_blocks_per_turn: int + block_size: int = 16 + prefix_diversity: float = 0.3 + session_interleave: bool = True + seed: int | None = None + + +def _block_id(label: str, idx: int) -> str: + """Generate a deterministic block ID using SHA-256.""" + return hashlib.sha256(f"{label}-{idx}".encode()).hexdigest()[:16] + + +def generate_synthetic_trace(config: SyntheticTraceConfig) -> List[dict]: + """Generate a synthetic trace from the given configuration. + + Returns a list of JSONL-compatible records, each representing + a single request in a multi-turn conversation. + """ + rng = random.Random(config.seed) + + # Determine prefix groups: groups = max(1, floor(diversity * num_sessions)) + num_groups = max(1, math.floor(config.prefix_diversity * config.num_sessions)) + + # Assign sessions to prefix groups + session_groups: List[int] = [] + for i in range(config.num_sessions): + session_groups.append(i % num_groups) + + # Pre-generate shared prefix block IDs for each group + group_prefixes: List[List[str]] = [] + for g in range(num_groups): + prefix_ids = [ + _block_id(f"prefix-{g}", idx) + for idx in range(config.shared_prefix_blocks) + ] + group_prefixes.append(prefix_ids) + + # Build request schedule + # If session_interleave is True, interleave turns from different sessions + # Otherwise, complete each session sequentially + if config.session_interleave: + # Round-robin across sessions for each turn + schedule: List[tuple] = [] # (session_idx, turn) + for turn in range(config.turns_per_session): + session_order = list(range(config.num_sessions)) + rng.shuffle(session_order) + for sid in session_order: + schedule.append((sid, turn)) + else: + schedule = [] + for sid in range(config.num_sessions): + for turn in range(config.turns_per_session): + schedule.append((sid, turn)) + + # Track per-session accumulated private blocks + session_private_blocks: List[List[str]] = [[] for _ in range(config.num_sessions)] + + records: List[dict] = [] + timestamp = 1000 # Start timestamp in ms + + for request_idx, (session_id, turn) in enumerate(schedule): + group_id = session_groups[session_id] + prefix_ids = group_prefixes[group_id] + + # Generate new unique blocks for this turn + new_blocks = [ + _block_id(f"session-{session_id}-turn-{turn}", idx) + for idx in range(config.avg_new_blocks_per_turn) + ] + + # The full hash_ids for this request: + # prefix blocks + accumulated private blocks + new blocks + accumulated_private = list(session_private_blocks[session_id]) + hash_ids = prefix_ids + accumulated_private + new_blocks + + # Update accumulated private blocks for next turn + session_private_blocks[session_id].extend(new_blocks) + + # Compute token lengths + input_length = len(hash_ids) * config.block_size + output_length = config.block_size # Minimal output per turn + + record = { + "request_id": f"req-{request_idx:06d}", + "chat_id": f"session-{session_id}", + "parent_chat_id": f"session-{session_id}" if turn > 0 else None, + "turn": turn + 1, + "type": "text", + "timestamp": timestamp, + "input_length": input_length, + "output_length": output_length, + "hash_ids": hash_ids, + } + records.append(record) + timestamp += rng.randint(10, 100) + + return records diff --git a/tests/test_cli_generate_trace.py b/tests/test_cli_generate_trace.py new file mode 100644 index 0000000..f16547d --- /dev/null +++ b/tests/test_cli_generate_trace.py @@ -0,0 +1,158 @@ +"""Tests for the generate-trace CLI command.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from tests import _bootstrap # noqa: F401 + +from kvcache_upper_bound.cli.main import main + + +class CLIGenerateTraceTest(unittest.TestCase): + """Tests for the generate-trace CLI subcommand.""" + + def test_generates_jsonl_output(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "trace.jsonl" + import sys + original_argv = sys.argv + try: + sys.argv = [ + "kvcache", + "generate-trace", + "--sessions", "3", + "--turns", "2", + "--shared-prefix-blocks", "4", + "--new-blocks-per-turn", "2", + "--seed", "42", + "--output", str(output_path), + ] + result = main() + finally: + sys.argv = original_argv + + self.assertEqual(result, 0) + self.assertTrue(output_path.exists()) + + # Verify JSONL content + lines = output_path.read_text().strip().split("\n") + self.assertEqual(len(lines), 6) # 3 sessions * 2 turns + + for line in lines: + record = json.loads(line) + self.assertIn("request_id", record) + self.assertIn("hash_ids", record) + self.assertIn("chat_id", record) + self.assertIn("turn", record) + self.assertIn("input_length", record) + + def test_correct_record_count(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "trace.jsonl" + import sys + original_argv = sys.argv + try: + sys.argv = [ + "kvcache", + "generate-trace", + "--sessions", "5", + "--turns", "4", + "--shared-prefix-blocks", "2", + "--new-blocks-per-turn", "3", + "--output", str(output_path), + ] + result = main() + finally: + sys.argv = original_argv + + self.assertEqual(result, 0) + lines = output_path.read_text().strip().split("\n") + self.assertEqual(len(lines), 20) # 5 * 4 + + def test_prefix_diversity_parameter(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "trace.jsonl" + import sys + original_argv = sys.argv + try: + sys.argv = [ + "kvcache", + "generate-trace", + "--sessions", "4", + "--turns", "1", + "--shared-prefix-blocks", "3", + "--new-blocks-per-turn", "1", + "--prefix-diversity", "0.0", + "--seed", "10", + "--output", str(output_path), + ] + result = main() + finally: + sys.argv = original_argv + + self.assertEqual(result, 0) + lines = output_path.read_text().strip().split("\n") + records = [json.loads(line) for line in lines] + + # All sessions should share the same prefix + prefixes = set() + for r in records: + prefixes.add(tuple(r["hash_ids"][:3])) + self.assertEqual(len(prefixes), 1) + + def test_block_size_parameter(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "trace.jsonl" + import sys + original_argv = sys.argv + try: + sys.argv = [ + "kvcache", + "generate-trace", + "--sessions", "2", + "--turns", "1", + "--shared-prefix-blocks", "2", + "--new-blocks-per-turn", "1", + "--block-size", "32", + "--seed", "42", + "--output", str(output_path), + ] + result = main() + finally: + sys.argv = original_argv + + self.assertEqual(result, 0) + lines = output_path.read_text().strip().split("\n") + record = json.loads(lines[0]) + # 2 prefix + 1 new = 3 blocks * 32 = 96 + self.assertEqual(record["input_length"], 3 * 32) + + def test_creates_parent_directories(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "subdir" / "nested" / "trace.jsonl" + import sys + original_argv = sys.argv + try: + sys.argv = [ + "kvcache", + "generate-trace", + "--sessions", "1", + "--turns", "1", + "--shared-prefix-blocks", "1", + "--new-blocks-per-turn", "1", + "--output", str(output_path), + ] + result = main() + finally: + sys.argv = original_argv + + self.assertEqual(result, 0) + self.assertTrue(output_path.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_synthetic_generator.py b/tests/test_synthetic_generator.py new file mode 100644 index 0000000..5d3424c --- /dev/null +++ b/tests/test_synthetic_generator.py @@ -0,0 +1,221 @@ +"""Tests for the parametric synthetic trace generator.""" + +from __future__ import annotations + +import json +import unittest + +from tests import _bootstrap # noqa: F401 + +from kvcache_upper_bound.synthetic import SyntheticTraceConfig, generate_synthetic_trace + + +class SyntheticGeneratorBasicTest(unittest.TestCase): + """Basic generation tests.""" + + def test_basic_generation_record_count(self) -> None: + config = SyntheticTraceConfig( + num_sessions=4, + turns_per_session=3, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + seed=42, + ) + records = generate_synthetic_trace(config) + self.assertEqual(len(records), 4 * 3) + + def test_record_fields_present(self) -> None: + config = SyntheticTraceConfig( + num_sessions=2, + turns_per_session=2, + shared_prefix_blocks=1, + avg_new_blocks_per_turn=1, + seed=0, + ) + records = generate_synthetic_trace(config) + required_fields = { + "request_id", "chat_id", "parent_chat_id", "turn", + "type", "timestamp", "input_length", "output_length", "hash_ids", + } + for record in records: + self.assertTrue(required_fields.issubset(record.keys()), f"Missing fields in {record}") + + def test_timestamps_monotonically_increase(self) -> None: + config = SyntheticTraceConfig( + num_sessions=3, + turns_per_session=4, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + seed=7, + ) + records = generate_synthetic_trace(config) + timestamps = [r["timestamp"] for r in records] + for i in range(1, len(timestamps)): + self.assertGreater(timestamps[i], timestamps[i - 1]) + + def test_deterministic_with_seed(self) -> None: + config = SyntheticTraceConfig( + num_sessions=3, + turns_per_session=2, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + seed=123, + ) + records_a = generate_synthetic_trace(config) + records_b = generate_synthetic_trace(config) + self.assertEqual(records_a, records_b) + + def test_input_length_matches_hash_ids(self) -> None: + config = SyntheticTraceConfig( + num_sessions=2, + turns_per_session=3, + shared_prefix_blocks=3, + avg_new_blocks_per_turn=2, + block_size=16, + seed=42, + ) + records = generate_synthetic_trace(config) + for record in records: + expected_length = len(record["hash_ids"]) * config.block_size + self.assertEqual(record["input_length"], expected_length) + + +class SyntheticGeneratorPrefixSharingTest(unittest.TestCase): + """Tests for prefix sharing behavior.""" + + def test_zero_diversity_all_same_prefix(self) -> None: + """With diversity=0.0, all sessions share the same prefix.""" + config = SyntheticTraceConfig( + num_sessions=5, + turns_per_session=2, + shared_prefix_blocks=4, + avg_new_blocks_per_turn=1, + prefix_diversity=0.0, + session_interleave=False, + seed=42, + ) + records = generate_synthetic_trace(config) + # Extract prefix portion (first shared_prefix_blocks items) from each record + prefixes = set() + for record in records: + prefix = tuple(record["hash_ids"][:config.shared_prefix_blocks]) + prefixes.add(prefix) + # All sessions should share the same prefix (1 group) + self.assertEqual(len(prefixes), 1) + + def test_high_diversity_multiple_prefixes(self) -> None: + """With diversity=1.0, each session gets its own prefix group.""" + config = SyntheticTraceConfig( + num_sessions=5, + turns_per_session=2, + shared_prefix_blocks=4, + avg_new_blocks_per_turn=1, + prefix_diversity=1.0, + session_interleave=False, + seed=42, + ) + records = generate_synthetic_trace(config) + # Each session should have a unique prefix + session_prefixes = {} + for record in records: + sid = record["chat_id"] + prefix = tuple(record["hash_ids"][:config.shared_prefix_blocks]) + if sid not in session_prefixes: + session_prefixes[sid] = prefix + else: + # Same session should always have same prefix + self.assertEqual(session_prefixes[sid], prefix) + # Number of unique prefixes should equal number of sessions + unique_prefixes = set(session_prefixes.values()) + self.assertEqual(len(unique_prefixes), 5) + + def test_medium_diversity_groups(self) -> None: + """With diversity=0.5, sessions are split into groups.""" + config = SyntheticTraceConfig( + num_sessions=10, + turns_per_session=1, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=1, + prefix_diversity=0.5, + session_interleave=False, + seed=42, + ) + records = generate_synthetic_trace(config) + prefixes = set() + for record in records: + prefix = tuple(record["hash_ids"][:config.shared_prefix_blocks]) + prefixes.add(prefix) + # groups = max(1, floor(0.5 * 10)) = 5 + self.assertEqual(len(prefixes), 5) + + +class SyntheticGeneratorAccumulationTest(unittest.TestCase): + """Tests for block accumulation across turns.""" + + def test_blocks_accumulate_across_turns(self) -> None: + config = SyntheticTraceConfig( + num_sessions=1, + turns_per_session=3, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + session_interleave=False, + seed=42, + ) + records = generate_synthetic_trace(config) + # Turn 1: prefix(2) + new(2) = 4 blocks + self.assertEqual(len(records[0]["hash_ids"]), 4) + # Turn 2: prefix(2) + accumulated(2) + new(2) = 6 blocks + self.assertEqual(len(records[1]["hash_ids"]), 6) + # Turn 3: prefix(2) + accumulated(4) + new(2) = 8 blocks + self.assertEqual(len(records[2]["hash_ids"]), 8) + + def test_turn_numbers_sequential(self) -> None: + config = SyntheticTraceConfig( + num_sessions=1, + turns_per_session=4, + shared_prefix_blocks=1, + avg_new_blocks_per_turn=1, + session_interleave=False, + seed=42, + ) + records = generate_synthetic_trace(config) + for i, record in enumerate(records): + self.assertEqual(record["turn"], i + 1) + + +class SyntheticGeneratorSerializationTest(unittest.TestCase): + """Tests for JSONL serialization compatibility.""" + + def test_jsonl_serializable(self) -> None: + config = SyntheticTraceConfig( + num_sessions=3, + turns_per_session=2, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + seed=42, + ) + records = generate_synthetic_trace(config) + # Each record should be JSON-serializable + for record in records: + line = json.dumps(record) + parsed = json.loads(line) + self.assertEqual(parsed["request_id"], record["request_id"]) + self.assertEqual(parsed["hash_ids"], record["hash_ids"]) + + def test_hash_ids_are_strings(self) -> None: + config = SyntheticTraceConfig( + num_sessions=2, + turns_per_session=2, + shared_prefix_blocks=2, + avg_new_blocks_per_turn=2, + seed=42, + ) + records = generate_synthetic_trace(config) + for record in records: + for block_id in record["hash_ids"]: + self.assertIsInstance(block_id, str) + self.assertEqual(len(block_id), 16) # sha256 hex truncated to 16 + + +if __name__ == "__main__": + unittest.main() diff --git a/website/calculator.html b/website/calculator.html index 4f105d8..30ac081 100644 --- a/website/calculator.html +++ b/website/calculator.html @@ -616,6 +616,49 @@ + + +
+
Synthetic Trace Generator Experimental
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+ +
@@ -906,6 +949,27 @@ if (efficiency <= 0) return Infinity; return this.shared_prefix_tokens + this.totalPrivateWorkingSetTokens() / efficiency; } + perTurnReusablePrivateTokens() { + const result = []; + for (let step = 0; step < this.avg_turns_per_session; step++) { + result.push(Math.min(this.private_window_tokens, step * this.avg_new_tokens_per_turn)); + } + return result; + } + perTurnContentHitRates() { + const rates = []; + for (let step = 0; step < this.avg_turns_per_session; step++) { + const privateReuse = Math.min(this.private_window_tokens, step * this.avg_new_tokens_per_turn); + const requestTokens = this.shared_prefix_tokens + this.avg_new_tokens_per_turn + privateReuse; + if (requestTokens <= 0) { + rates.push(0.0); + } else { + const hit = this.shared_prefix_tokens + privateReuse; + rates.push(Math.min(1.0, hit / requestTokens)); + } + } + return rates; + } } function hitRateForCapacityTokens(capacityTokens, heuristic, efficiency) { @@ -1614,6 +1678,76 @@ }).join(""); } + function renderPerTurnChart(config) { + const heuristic = new MultiAgentHeuristic(config.heuristic_multi_agent); + const hitRates = heuristic.perTurnContentHitRates(); + const privateTokens = heuristic.perTurnReusablePrivateTokens(); + const labels = hitRates.map((_, i) => "Turn " + (i + 1)); + const card = document.getElementById("per-turn-chart-card"); + card.style.display = ""; + + const ctx = document.getElementById("perTurnChart").getContext("2d"); + if (perTurnChart) perTurnChart.destroy(); + perTurnChart = new Chart(ctx, { + type: "bar", + data: { + labels, + datasets: [ + { + label: "Content Hit Rate (%)", + data: hitRates.map(r => r * 100), + backgroundColor: "rgba(88,166,255,0.5)", + borderColor: "#58a6ff", + borderWidth: 1, + yAxisID: "y", + order: 2 + }, + { + label: "Reusable Private Tokens", + data: privateTokens, + type: "line", + borderColor: "#3fb950", + backgroundColor: "rgba(63,185,80,0.1)", + tension: 0.3, + pointRadius: 4, + fill: false, + yAxisID: "y1", + order: 1 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + position: "left", + min: 0, + max: 100, + title: { display: true, text: "Content Hit Rate (%)", color: "#8b949e" }, + grid: { color: "rgba(48,54,61,0.5)" }, + ticks: { color: "#8b949e" } + }, + y1: { + position: "right", + min: 0, + title: { display: true, text: "Reusable Private Tokens", color: "#8b949e" }, + grid: { drawOnChartArea: false }, + ticks: { color: "#8b949e" } + }, + x: { + title: { display: true, text: "Conversation Turn", color: "#8b949e" }, + grid: { color: "rgba(48,54,61,0.5)" }, + ticks: { color: "#8b949e" } + } + }, + plugins: { + legend: { labels: { color: "#c9d1d9" } } + } + } + }); + } + function renderComparison(resultB) { if (!scenarioA) return; const panel = document.getElementById("compare-panel"); @@ -1666,6 +1800,7 @@

Scenario B (Current)

renderHitRateChart(result.rows, result.summary.contentHitRate, result.summary); renderTpsMachinesChart(config, result.summary); renderSensitivity(config); + renderPerTurnChart(config); if (scenarioA) renderComparison(result); validateInputs(config); } catch (e) { @@ -2143,6 +2278,170 @@

Analysis Results

if (select.querySelector(`option[value="${current}"]`)) select.value = current; } + // ═══════════════════════════════════════════════════════════════════ + // Synthetic Trace Generator + // ═══════════════════════════════════════════════════════════════════ + let synthTraceRecords = null; + + function synthBlockId(label, idx) { + const str = label + "-" + idx; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = ((hash << 5) - hash) + ch; + hash = hash & hash; + } + let hash2 = 0; + const str2 = str + "_salt"; + for (let i = 0; i < str2.length; i++) { + const ch = str2.charCodeAt(i); + hash2 = ((hash2 << 5) - hash2) + ch; + hash2 = hash2 & hash2; + } + return (hash >>> 0).toString(16).padStart(8, "0") + (hash2 >>> 0).toString(16).padStart(8, "0"); + } + + function generateSyntheticTrace(sessions, turnsPerSession, sharedPrefixBlocks, newBlocksPerTurn, blockSize, prefixDiversity) { + const numGroups = Math.max(1, Math.floor(prefixDiversity * sessions)); + const groupPrefixes = []; + for (let g = 0; g < numGroups; g++) { + const ids = []; + for (let i = 0; i < sharedPrefixBlocks; i++) { + ids.push(synthBlockId("prefix-" + g, i)); + } + groupPrefixes.push(ids); + } + const sessionGroups = []; + for (let s = 0; s < sessions; s++) { + sessionGroups.push(s % numGroups); + } + const records = []; + const sessionPrivate = Array.from({length: sessions}, () => []); + let timestamp = 1000; + let reqIdx = 0; + for (let turn = 0; turn < turnsPerSession; turn++) { + for (let sid = 0; sid < sessions; sid++) { + const gid = sessionGroups[sid]; + const prefix = groupPrefixes[gid]; + const newBlocks = []; + for (let i = 0; i < newBlocksPerTurn; i++) { + newBlocks.push(synthBlockId("session-" + sid + "-turn-" + turn, i)); + } + const hashIds = prefix.concat(sessionPrivate[sid], newBlocks); + sessionPrivate[sid] = sessionPrivate[sid].concat(newBlocks); + records.push({ + request_id: "req-" + String(reqIdx).padStart(6, "0"), + chat_id: "session-" + sid, + parent_chat_id: turn > 0 ? "session-" + sid : null, + turn: turn + 1, + type: "text", + timestamp: timestamp, + input_length: hashIds.length * blockSize, + output_length: blockSize, + hash_ids: hashIds, + }); + reqIdx++; + timestamp += 10 + Math.floor(Math.random() * 90); + } + } + return records; + } + + function analyzeSyntheticTrace(records) { + const globalBlockSet = new Set(); + let totalBlocks = 0; + let hitBlocks = 0; + for (const record of records) { + const hashIds = record.hash_ids; + let hits = 0; + for (const blockId of hashIds) { + if (globalBlockSet.has(blockId)) hits++; + } + hitBlocks += hits; + totalBlocks += hashIds.length; + for (const blockId of hashIds) { + globalBlockSet.add(blockId); + } + } + const uniqueBlocks = globalBlockSet.size; + const contentHitRate = totalBlocks > 0 ? hitBlocks / totalBlocks : 0; + const blockSessionCount = new Map(); + for (const record of records) { + const sid = record.chat_id; + const seen = new Set(); + for (const blockId of record.hash_ids) { + if (!seen.has(blockId)) { + seen.add(blockId); + if (!blockSessionCount.has(blockId)) blockSessionCount.set(blockId, new Set()); + blockSessionCount.get(blockId).add(sid); + } + } + } + let sharedBlocks = 0; + for (const [, sessions] of blockSessionCount) { + if (sessions.size > 1) sharedBlocks++; + } + const prefixSharingRatio = uniqueBlocks > 0 ? sharedBlocks / uniqueBlocks : 0; + return { requestCount: records.length, uniqueBlocks, contentHitRate, prefixSharingRatio }; + } + + function renderSynthResults(analysis) { + const container = document.getElementById("synth-metrics-grid"); + container.innerHTML = ` +
${analysis.requestCount}
Requests
+
${analysis.uniqueBlocks.toLocaleString()}
Unique Blocks
+
${(analysis.contentHitRate * 100).toFixed(1)}%
Content Hit Rate
+
${(analysis.prefixSharingRatio * 100).toFixed(1)}%
Prefix Sharing Ratio
+ `; + document.getElementById("synth-results").style.display = "block"; + document.getElementById("synth-download-btn").style.display = "inline-flex"; + document.getElementById("synth-apply-btn").style.display = "inline-flex"; + } + + function onSynthGenerate() { + const sessions = +document.getElementById("synth_sessions").value; + const turns = +document.getElementById("synth_turns").value; + const prefixBlocks = +document.getElementById("synth_prefix_blocks").value; + const newBlocks = +document.getElementById("synth_new_blocks").value; + const diversity = +document.getElementById("synth_prefix_diversity").value; + const blockSize = +document.getElementById("synth_block_size").value; + synthTraceRecords = generateSyntheticTrace(sessions, turns, prefixBlocks, newBlocks, blockSize, diversity); + const analysis = analyzeSyntheticTrace(synthTraceRecords); + renderSynthResults(analysis); + } + + function onSynthDownload() { + if (!synthTraceRecords) return; + const lines = synthTraceRecords.map(r => JSON.stringify(r)).join("\n"); + const blob = new Blob([lines + "\n"], { type: "application/jsonl" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "synthetic_trace.jsonl"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function onSynthApply() { + if (!synthTraceRecords) return; + const sessions = +document.getElementById("synth_sessions").value; + const turns = +document.getElementById("synth_turns").value; + const prefixBlocks = +document.getElementById("synth_prefix_blocks").value; + const newBlocks = +document.getElementById("synth_new_blocks").value; + const blockSize = +document.getElementById("synth_block_size").value; + const sharedPrefixTokens = prefixBlocks * blockSize; + const avgNewTokensPerTurn = newBlocks * blockSize; + const privateWindowTokens = turns * avgNewTokensPerTurn; + document.getElementById("concurrent_agents").value = sessions; + document.getElementById("shared_prefix_tokens").value = sharedPrefixTokens; + document.getElementById("avg_new_tokens_per_turn").value = avgNewTokensPerTurn; + document.getElementById("avg_turns_per_session").value = turns; + document.getElementById("private_window_tokens").value = privateWindowTokens; + scheduleRecalc(); + } + // ═══════════════════════════════════════════════════════════════════ // GPU Hardware Dropdown (Deployment form) // ═══════════════════════════════════════════════════════════════════ @@ -2225,9 +2524,16 @@

Analysis Results

if (e.target === e.currentTarget) closeWizard(); }); - // Wire all inputs for recalculation + // Synthetic trace generator buttons + document.getElementById("synth-generate-btn").addEventListener("click", onSynthGenerate); + document.getElementById("synth-download-btn").addEventListener("click", onSynthDownload); + document.getElementById("synth-apply-btn").addEventListener("click", onSynthApply); + + // Wire all inputs for recalculation (exclude synthetic trace inputs) + const synthIds = new Set(["synth_sessions", "synth_turns", "synth_prefix_blocks", "synth_new_blocks", "synth_prefix_diversity", "synth_block_size"]); document.querySelectorAll("input, select").forEach(el => { if (el.id === "preset-select" || el.id === "model-preset-select" || el.id === "model-search" || el.id === "gpu-hardware-select") return; + if (synthIds.has(el.id)) return; el.addEventListener("input", scheduleRecalc); el.addEventListener("change", scheduleRecalc); });