diff --git a/projects/privacy-guard/analysis/README.md b/projects/privacy-guard/analysis/README.md new file mode 100644 index 0000000..ddeebc6 --- /dev/null +++ b/projects/privacy-guard/analysis/README.md @@ -0,0 +1,73 @@ +# Privacy Guard latency analysis + +This directory contains the source data, deterministic renderer, and generated +Privacy Guard latency-versus-prompt-size figure. It is the reusable starting +point for future documentation and a Dev Note about the proof-of-concept stress +test. + +## Recreate the figure + +Run from `projects/privacy-guard/`: + +```sh +uv run python analysis/render_latency_plot.py +``` + +The command writes: + +```text +analysis/privacy-guard-latency-vs-prompt-size.svg +``` + +Verify that the committed figure matches the data and renderer: + +```sh +uv run python analysis/render_latency_plot.py --check +``` + +The renderer uses only the Python standard library and does not add a project +dependency. + +## Data + +`privacy-guard-latency.csv` contains 96 joined observations from the synthetic +Claude Code context-growth experiment run on July 27–28, 2026. + +| Column | Meaning | +| --- | --- | +| `observed_at_utc` | Timestamp used to correlate the request across available logs | +| `prompt_tokens` | Claude Code session token accounting for the provider request | +| `privacy_guard_latency_ms` | `duration_ms` emitted by the `privacy_guard_evaluation` service log | +| `entity_count` | Aggregate Privacy Guard finding count | +| `phase` | Baseline, pre-compaction, compaction-trigger, or context-rebuild phase | +| `openshell_observed_ms` | OpenShell L7 request event to middleware-result event | +| `first_output_elapsed_ms` | OpenShell L7 request event to the first Claude response output | +| `turn_elapsed_ms` | OpenShell L7 request event to the last Claude response output | + +The baseline observations have only the service-side measurement, token count, +and entity count. Thirteen large-context observations also have the +OpenShell-observed middleware interval. Twelve of those completed turns have +first- and last-output timing; the compaction-triggering request does not. + +The figure's 0.56% annotation is the arithmetic mean of +`privacy_guard_latency_ms / turn_elapsed_ms` across those 12 completed turns. +It is not Privacy Guard's share of the short OpenShell middleware interval. +Point color uses a continuous scale for `entity_count`; each SVG point also +contains an accessible tooltip with its token, latency, and entity values. The +SVG adapts its text, grid, threshold, and point-outline colors to the viewer's +light or dark color scheme. + +## Interpretation limits + +This is a proof-of-concept observation set, not a general benchmark: + +- the workload used synthetic, deliberately repeated text +- prompt size and entity count increased together +- the run used one host, sandbox, Privacy Guard configuration, RegexEngine + policy, and Claude Code session +- baseline and large-context observations span a Privacy Guard process restart +- the linear fit is descriptive and should not be treated as a performance + guarantee + +The CSV contains operational timing and aggregate counts only. It contains no +request text or detected entity values. diff --git a/projects/privacy-guard/analysis/privacy-guard-latency-vs-prompt-size.svg b/projects/privacy-guard/analysis/privacy-guard-latency-vs-prompt-size.svg new file mode 100644 index 0000000..e6b1866 --- /dev/null +++ b/projects/privacy-guard/analysis/privacy-guard-latency-vs-prompt-size.svg @@ -0,0 +1,162 @@ + +Privacy Guard latency versus prompt size +Scatter plot of 96 Privacy Guard service latency measurements from 18 thousand to 1.141 million prompt tokens, with one linear fit and a one-million-token threshold. Privacy Guard averaged 0.56 percent of end-to-end time across 12 completed turns. + + + + + + + + + +Privacy Guard latency (ms) · log scale + +10k + +30k + +100k + +300k + +1M + +5 + +10 + +20 + +50 + +100 + +250 + +1M threshold + +18,291 tokens; 5.5 ms; 3 entities detected +18,722 tokens; 6.0 ms; 3 entities detected +18,923 tokens; 6.0 ms; 4 entities detected +19,120 tokens; 10.5 ms; 4 entities detected +19,378 tokens; 12.5 ms; 6 entities detected +19,798 tokens; 21.7 ms; 6 entities detected +20,335 tokens; 7.8 ms; 6 entities detected +21,404 tokens; 6.9 ms; 11 entities detected +22,377 tokens; 8.8 ms; 11 entities detected +22,934 tokens; 9.7 ms; 11 entities detected +23,528 tokens; 7.8 ms; 15 entities detected +23,866 tokens; 10.9 ms; 16 entities detected +26,472 tokens; 10.9 ms; 22 entities detected +26,853 tokens; 11.7 ms; 22 entities detected +28,021 tokens; 12.0 ms; 22 entities detected +28,867 tokens; 11.0 ms; 26 entities detected +31,351 tokens; 12.8 ms; 26 entities detected +33,610 tokens; 14.6 ms; 26 entities detected +34,193 tokens; 14.5 ms; 26 entities detected +20,747 tokens; 10.8 ms; 19 entities detected +22,310 tokens; 18.2 ms; 20 entities detected +23,103 tokens; 16.4 ms; 20 entities detected +27,015 tokens; 28.3 ms; 22 entities detected +31,441 tokens; 13.0 ms; 22 entities detected +38,362 tokens; 12.4 ms; 23 entities detected +39,098 tokens; 18.3 ms; 23 entities detected +39,471 tokens; 17.0 ms; 23 entities detected +40,025 tokens; 10.9 ms; 23 entities detected +47,124 tokens; 14.8 ms; 23 entities detected +47,960 tokens; 11.6 ms; 23 entities detected +48,251 tokens; 17.6 ms; 23 entities detected +51,102 tokens; 13.0 ms; 23 entities detected +51,552 tokens; 11.3 ms; 23 entities detected +52,216 tokens; 14.3 ms; 23 entities detected +52,634 tokens; 11.1 ms; 23 entities detected +53,244 tokens; 20.7 ms; 23 entities detected +62,423 tokens; 13.7 ms; 23 entities detected +63,068 tokens; 17.8 ms; 23 entities detected +63,486 tokens; 17.5 ms; 23 entities detected +64,295 tokens; 26.0 ms; 23 entities detected +76,372 tokens; 18.9 ms; 23 entities detected +77,031 tokens; 18.1 ms; 23 entities detected +82,339 tokens; 26.1 ms; 24 entities detected +83,184 tokens; 36.0 ms; 24 entities detected +88,677 tokens; 20.9 ms; 24 entities detected +89,748 tokens; 18.7 ms; 24 entities detected +89,970 tokens; 20.0 ms; 24 entities detected +90,530 tokens; 21.0 ms; 24 entities detected +91,420 tokens; 21.7 ms; 24 entities detected +92,901 tokens; 22.7 ms; 24 entities detected +93,678 tokens; 36.0 ms; 24 entities detected +95,694 tokens; 25.2 ms; 24 entities detected +96,704 tokens; 30.2 ms; 24 entities detected +96,868 tokens; 27.0 ms; 24 entities detected +97,886 tokens; 30.3 ms; 24 entities detected +98,356 tokens; 24.5 ms; 24 entities detected +99,265 tokens; 35.3 ms; 24 entities detected +101,025 tokens; 34.2 ms; 24 entities detected +101,193 tokens; 23.3 ms; 24 entities detected +102,071 tokens; 26.9 ms; 24 entities detected +102,481 tokens; 23.7 ms; 24 entities detected +104,787 tokens; 29.4 ms; 24 entities detected +105,313 tokens; 31.9 ms; 24 entities detected +105,801 tokens; 35.6 ms; 24 entities detected +105,958 tokens; 39.6 ms; 24 entities detected +106,160 tokens; 33.4 ms; 25 entities detected +106,428 tokens; 24.1 ms; 25 entities detected +106,807 tokens; 47.7 ms; 25 entities detected +106,981 tokens; 43.3 ms; 25 entities detected +107,412 tokens; 27.1 ms; 25 entities detected +107,647 tokens; 32.0 ms; 25 entities detected +108,052 tokens; 38.2 ms; 25 entities detected +108,800 tokens; 36.9 ms; 25 entities detected +114,425 tokens; 32.0 ms; 25 entities detected +114,584 tokens; 32.3 ms; 25 entities detected +115,395 tokens; 24.7 ms; 25 entities detected +115,852 tokens; 39.1 ms; 25 entities detected +117,730 tokens; 42.9 ms; 25 entities detected +120,732 tokens; 36.0 ms; 25 entities detected +122,710 tokens; 47.4 ms; 27 entities detected +127,634 tokens; 38.6 ms; 59 entities detected +136,816 tokens; 49.1 ms; 59 entities detected +143,349 tokens; 41.2 ms; 59 entities detected +323,611 tokens; 93.4 ms; 259 entities detected +324,158 tokens; 96.5 ms; 259 entities detected +325,804 tokens; 95.2 ms; 259 entities detected +329,592 tokens; 91.0 ms; 259 entities detected +656,648 tokens; 160.3 ms; 323 entities detected +665,140 tokens; 164.3 ms; 323 entities detected +963,788 tokens; 220.3 ms; 389 entities detected +965,007 tokens; 216.3 ms; 389 entities detected +1,140,979 tokens; 268.7 ms; 429 entities detected +228,409 tokens; 54.2 ms; 43 entities detected +428,405 tokens; 103.4 ms; 83 entities detected +628,418 tokens; 196.2 ms; 123 entities detected +801,456 tokens; 176.0 ms; 163 entities detected +Privacy Guard averaged 0.56% +of end-to-end turn time +across 12 completed turns +Entities detected + +Prompt tokens · log scale +Single fit across 96 measurements: 6.69 + 23.28 ms per 100k tokens · R² 0.976 + +3 + +100 + +200 + +300 + +429 + diff --git a/projects/privacy-guard/analysis/privacy-guard-latency.csv b/projects/privacy-guard/analysis/privacy-guard-latency.csv new file mode 100644 index 0000000..d123e0b --- /dev/null +++ b/projects/privacy-guard/analysis/privacy-guard-latency.csv @@ -0,0 +1,97 @@ +observed_at_utc,prompt_tokens,privacy_guard_latency_ms,entity_count,phase,openshell_observed_ms,first_output_elapsed_ms,turn_elapsed_ms +2026-07-27T20:30:01.123Z,18291,5.500,3,baseline,,, +2026-07-27T20:30:16.777Z,18722,5.968,3,baseline,,, +2026-07-27T20:30:47.630Z,18923,6.032,4,baseline,,, +2026-07-27T20:33:01.353Z,19120,10.526,4,baseline,,, +2026-07-27T20:40:29.558Z,19378,12.501,6,baseline,,, +2026-07-27T20:52:52.194Z,19798,21.712,6,baseline,,, +2026-07-27T20:52:57.011Z,20335,7.766,6,baseline,,, +2026-07-27T20:53:09.407Z,21404,6.914,11,baseline,,, +2026-07-27T20:53:15.951Z,22377,8.772,11,baseline,,, +2026-07-27T20:53:59.529Z,22934,9.744,11,baseline,,, +2026-07-27T20:54:09.916Z,23528,7.848,15,baseline,,, +2026-07-27T20:54:50.861Z,23866,10.930,16,baseline,,, +2026-07-27T20:55:03.900Z,26472,10.931,22,baseline,,, +2026-07-27T20:57:46.380Z,26853,11.706,22,baseline,,, +2026-07-27T20:58:02.786Z,28021,12.020,22,baseline,,, +2026-07-27T20:58:13.857Z,28867,11.031,26,baseline,,, +2026-07-27T20:58:57.295Z,31351,12.766,26,baseline,,, +2026-07-27T21:00:20.727Z,33610,14.624,26,baseline,,, +2026-07-27T21:00:30.405Z,34193,14.459,26,baseline,,, +2026-07-27T22:10:44.672Z,20747,10.780,19,baseline,,, +2026-07-27T22:27:43.549Z,22310,18.206,20,baseline,,, +2026-07-27T22:39:51.865Z,23103,16.403,20,baseline,,, +2026-07-28T14:12:13.541Z,27015,28.333,22,baseline,,, +2026-07-28T14:15:00.603Z,31441,13.022,22,baseline,,, +2026-07-28T14:17:30.678Z,38362,12.441,23,baseline,,, +2026-07-28T14:18:12.741Z,39098,18.293,23,baseline,,, +2026-07-28T14:20:18.406Z,39471,16.974,23,baseline,,, +2026-07-28T14:21:57.261Z,40025,10.865,23,baseline,,, +2026-07-28T14:23:44.194Z,47124,14.840,23,baseline,,, +2026-07-28T14:24:40.109Z,47960,11.560,23,baseline,,, +2026-07-28T14:28:52.430Z,48251,17.624,23,baseline,,, +2026-07-28T14:29:37.005Z,51102,12.951,23,baseline,,, +2026-07-28T14:29:53.910Z,51552,11.305,23,baseline,,, +2026-07-28T14:30:03.599Z,52216,14.286,23,baseline,,, +2026-07-28T14:30:09.616Z,52634,11.082,23,baseline,,, +2026-07-28T14:32:44.808Z,53244,20.658,23,baseline,,, +2026-07-28T14:34:57.319Z,62423,13.714,23,baseline,,, +2026-07-28T14:35:06.628Z,63068,17.805,23,baseline,,, +2026-07-28T14:35:13.210Z,63486,17.526,23,baseline,,, +2026-07-28T14:36:05.558Z,64295,25.973,23,baseline,,, +2026-07-28T14:39:49.555Z,76372,18.898,23,baseline,,, +2026-07-28T14:43:07.287Z,77031,18.117,23,baseline,,, +2026-07-28T14:44:51.978Z,82339,26.136,24,baseline,,, +2026-07-28T14:55:59.291Z,83184,36.034,24,baseline,,, +2026-07-28T14:57:22.789Z,88677,20.867,24,baseline,,, +2026-07-28T14:57:37.436Z,89748,18.688,24,baseline,,, +2026-07-28T14:57:45.152Z,89970,20.018,24,baseline,,, +2026-07-28T14:58:06.951Z,90530,21.013,24,baseline,,, +2026-07-28T14:58:20.033Z,91420,21.663,24,baseline,,, +2026-07-28T15:00:36.270Z,92901,22.735,24,baseline,,, +2026-07-28T15:05:37.739Z,93678,35.987,24,baseline,,, +2026-07-28T15:06:38.443Z,95694,25.222,24,baseline,,, +2026-07-28T15:08:19.742Z,96704,30.232,24,baseline,,, +2026-07-28T15:08:33.441Z,96868,27.002,24,baseline,,, +2026-07-28T15:08:51.517Z,97886,30.335,24,baseline,,, +2026-07-28T15:09:14.278Z,98356,24.542,24,baseline,,, +2026-07-28T15:09:41.191Z,99265,35.327,24,baseline,,, +2026-07-28T15:10:11.704Z,101025,34.153,24,baseline,,, +2026-07-28T15:10:39.514Z,101193,23.337,24,baseline,,, +2026-07-28T15:10:55.787Z,102071,26.929,24,baseline,,, +2026-07-28T15:11:05.095Z,102481,23.703,24,baseline,,, +2026-07-28T15:11:52.844Z,104787,29.402,24,baseline,,, +2026-07-28T15:12:09.526Z,105313,31.890,24,baseline,,, +2026-07-28T15:12:15.243Z,105801,35.567,24,baseline,,, +2026-07-28T15:12:18.828Z,105958,39.620,24,baseline,,, +2026-07-28T15:12:21.998Z,106160,33.373,25,baseline,,, +2026-07-28T15:12:34.783Z,106428,24.077,25,baseline,,, +2026-07-28T15:12:40.464Z,106807,47.700,25,baseline,,, +2026-07-28T15:12:43.512Z,106981,43.296,25,baseline,,, +2026-07-28T15:13:14.543Z,107412,27.077,25,baseline,,, +2026-07-28T15:13:19.116Z,107647,32.041,25,baseline,,, +2026-07-28T15:13:30.257Z,108052,38.225,25,baseline,,, +2026-07-28T15:14:50.650Z,108800,36.868,25,baseline,,, +2026-07-28T15:16:10.735Z,114425,32.048,25,baseline,,, +2026-07-28T15:19:26.270Z,114584,32.318,25,baseline,,, +2026-07-28T15:19:40.244Z,115395,24.684,25,baseline,,, +2026-07-28T15:19:57.190Z,115852,39.067,25,baseline,,, +2026-07-28T15:20:22.819Z,117730,42.895,25,baseline,,, +2026-07-28T15:26:43.185Z,120732,36.043,25,baseline,,, +2026-07-28T15:28:43.793Z,122710,47.417,27,baseline,,, +2026-07-28T15:34:42.276Z,127634,38.612,59,baseline,,, +2026-07-28T15:37:03.144Z,136816,49.149,59,baseline,,, +2026-07-28T15:43:12.912Z,143349,41.177,59,baseline,,, +2026-07-28T16:46:47.621Z,323611,93.396,259,pre_compaction,120,13085.000,14496.000 +2026-07-28T16:47:02.185Z,324158,96.490,259,pre_compaction,110,26868.000,30972.000 +2026-07-28T16:47:33.245Z,325804,95.200,259,pre_compaction,110,13488.000,23734.000 +2026-07-28T16:50:37.017Z,329592,91.015,259,pre_compaction,110,46988.000,59311.000 +2026-07-28T16:51:36.588Z,656648,160.287,323,pre_compaction,189,60541.000,91668.000 +2026-07-28T16:54:02.960Z,665140,164.283,323,pre_compaction,198,22893.000,34883.000 +2026-07-28T16:54:38.109Z,963788,220.276,389,pre_compaction,264,13133.000,26286.000 +2026-07-28T16:56:39.208Z,965007,216.278,389,pre_compaction,263,20056.000,28635.000 +2026-07-28T16:57:08.108Z,1140979,268.666,429,compaction_trigger,344,, +2026-07-28T17:00:21.608Z,228409,54.221,43,context_rebuild,66,9571.000,17892.000 +2026-07-28T17:00:39.748Z,428405,103.414,83,context_rebuild,128,6836.000,14623.000 +2026-07-28T17:00:54.757Z,628418,196.250,123,context_rebuild,244,7270.000,14525.000 +2026-07-28T17:01:09.519Z,801456,176.012,163,context_rebuild,225,16508.000,31315.000 diff --git a/projects/privacy-guard/analysis/render_latency_plot.py b/projects/privacy-guard/analysis/render_latency_plot.py new file mode 100644 index 0000000..d3bff43 --- /dev/null +++ b/projects/privacy-guard/analysis/render_latency_plot.py @@ -0,0 +1,392 @@ +"""Render the Privacy Guard latency proof-of-concept figure as deterministic SVG.""" + +from __future__ import annotations + +import argparse +import csv +import math +import statistics +from dataclasses import dataclass +from pathlib import Path + +_ANALYSIS_DIR = Path(__file__).resolve().parent +_DEFAULT_DATA = _ANALYSIS_DIR / "privacy-guard-latency.csv" +_DEFAULT_OUTPUT = _ANALYSIS_DIR / "privacy-guard-latency-vs-prompt-size.svg" + +_WIDTH = 1200 +_HEIGHT = 720 +_MARGIN_LEFT = 105 +_MARGIN_RIGHT = 65 +_MARGIN_TOP = 75 +_MARGIN_BOTTOM = 105 +_X_MIN = 10_000.0 +_X_MAX = 1_200_000.0 +_Y_MIN = 4.0 +_Y_MAX = 400.0 +_CONTEXT_THRESHOLD = 1_000_000.0 +_COLORBAR_X = 650 +_COLORBAR_WIDTH = 330 +_ENTITY_COLORS = ( + (0.00, (43, 10, 61)), + (0.25, (123, 47, 142)), + (0.50, (213, 82, 105)), + (0.75, (245, 137, 76)), + (1.00, (247, 209, 61)), +) + + +@dataclass(frozen=True) +class Measurement: + """One joined latency observation.""" + + observed_at_utc: str + prompt_tokens: int + privacy_guard_latency_ms: float + entity_count: int + phase: str + openshell_observed_ms: float | None + first_output_elapsed_ms: float | None + turn_elapsed_ms: float | None + + +@dataclass(frozen=True) +class LinearFit: + """Ordinary least-squares fit of latency against tokens.""" + + intercept_ms: float + milliseconds_per_100k_tokens: float + r_squared: float + + def predict(self, prompt_tokens: float) -> float: + """Return the fitted latency for one prompt size.""" + return self.intercept_ms + self.milliseconds_per_100k_tokens * ( + prompt_tokens / 100_000.0 + ) + + +def main() -> None: + """Load observations and write or verify the generated SVG.""" + parser = argparse.ArgumentParser() + parser.add_argument("--data", type=Path, default=_DEFAULT_DATA) + parser.add_argument("--output", type=Path, default=_DEFAULT_OUTPUT) + parser.add_argument( + "--check", + action="store_true", + help="Fail when the committed SVG does not match a fresh render.", + ) + args = parser.parse_args() + + measurements = _load_measurements(args.data) + fit = _linear_fit(measurements) + svg = _render_svg(measurements, fit) + + if args.check: + if not args.output.exists() or args.output.read_text(encoding="utf-8") != svg: + raise SystemExit( + f"{args.output} is missing or stale; rerun without --check" + ) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(svg, encoding="utf-8") + + completed_turns = [row for row in measurements if row.turn_elapsed_ms is not None] + mean_turn_share = statistics.fmean( + row.privacy_guard_latency_ms / row.turn_elapsed_ms + for row in completed_turns + if row.turn_elapsed_ms is not None + ) + print( + f"{len(measurements)} measurements; " + f"fit={fit.intercept_ms:.2f} + " + f"{fit.milliseconds_per_100k_tokens:.2f} ms/100k tokens; " + f"R²={fit.r_squared:.3f}; " + f"mean turn share={100.0 * mean_turn_share:.2f}%" + ) + + +def _load_measurements(path: Path) -> list[Measurement]: + rows: list[Measurement] = [] + with path.open(encoding="utf-8", newline="") as source: + for row in csv.DictReader(source): + rows.append( + Measurement( + observed_at_utc=row["observed_at_utc"], + prompt_tokens=int(row["prompt_tokens"]), + privacy_guard_latency_ms=float(row["privacy_guard_latency_ms"]), + entity_count=int(row["entity_count"]), + phase=row["phase"], + openshell_observed_ms=_optional_float(row["openshell_observed_ms"]), + first_output_elapsed_ms=_optional_float( + row["first_output_elapsed_ms"] + ), + turn_elapsed_ms=_optional_float(row["turn_elapsed_ms"]), + ) + ) + if len(rows) < 2: + raise ValueError("at least two measurements are required") + return rows + + +def _optional_float(value: str) -> float | None: + return float(value) if value else None + + +def _linear_fit(measurements: list[Measurement]) -> LinearFit: + x_values = [row.prompt_tokens / 100_000.0 for row in measurements] + y_values = [row.privacy_guard_latency_ms for row in measurements] + x_mean = statistics.fmean(x_values) + y_mean = statistics.fmean(y_values) + x_variance = sum((value - x_mean) ** 2 for value in x_values) + if x_variance == 0.0: + raise ValueError("prompt-token observations must not all be equal") + slope = ( + sum( + (x_value - x_mean) * (y_value - y_mean) + for x_value, y_value in zip(x_values, y_values, strict=True) + ) + / x_variance + ) + intercept = y_mean - slope * x_mean + residual_sum = sum( + (y_value - (intercept + slope * x_value)) ** 2 + for x_value, y_value in zip(x_values, y_values, strict=True) + ) + total_sum = sum((value - y_mean) ** 2 for value in y_values) + r_squared = 1.0 - residual_sum / total_sum + return LinearFit( + intercept_ms=intercept, + milliseconds_per_100k_tokens=slope, + r_squared=r_squared, + ) + + +def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: + plot_right = _WIDTH - _MARGIN_RIGHT + plot_bottom = _HEIGHT - _MARGIN_BOTTOM + minimum_entities = min(row.entity_count for row in measurements) + maximum_entities = max(row.entity_count for row in measurements) + completed_turns = [row for row in measurements if row.turn_elapsed_ms is not None] + mean_turn_share = statistics.fmean( + row.privacy_guard_latency_ms / row.turn_elapsed_ms + for row in completed_turns + if row.turn_elapsed_ms is not None + ) + + parts = [ + ( + f'' + ), + 'Privacy Guard latency versus prompt size', + ( + 'Scatter plot of 96 Privacy Guard service ' + "latency measurements from 18 thousand to 1.141 million prompt " + "tokens, with one linear fit and a one-million-token threshold. " + f"Privacy Guard averaged {100.0 * mean_turn_share:.2f} percent of " + "end-to-end time across 12 completed turns." + ), + "", + '', + *[ + f'' + for position, color in _ENTITY_COLORS + ], + "", + "", + ( + f'' + "Privacy Guard latency (ms) · log scale" + ), + ] + + for value in (10_000, 30_000, 100_000, 300_000, 1_000_000): + x = _x(value) + parts.append( + f'' + ) + anchor = "start" if value == 10_000 else "middle" + parts.append( + f'{_format_tokens(value)}' + ) + + for value in (5, 10, 20, 50, 100, 250): + y = _y(value) + parts.append( + f'' + ) + parts.append( + f'{value}' + ) + + threshold_x = _x(_CONTEXT_THRESHOLD) + parts.extend( + [ + ( + f'' + ), + ( + f'1M threshold' + ), + ] + ) + + fit_points = [] + token_value = _X_MIN + while token_value < _X_MAX: + fit_points.append(f"{_x(token_value):.2f},{_y(fit.predict(token_value)):.2f}") + token_value *= 1.06 + fit_points.append(f"{_x(_X_MAX):.2f},{_y(fit.predict(_X_MAX)):.2f}") + parts.append(f'') + + for row in measurements: + point_color = _entity_color( + row.entity_count, + minimum=minimum_entities, + maximum=maximum_entities, + ) + parts.append( + f'' + f"{row.prompt_tokens:,} tokens; " + f"{row.privacy_guard_latency_ms:.1f} ms; " + f"{row.entity_count} entities detected" + ) + + share_x = _x(14_000) + share_y = _y(90) + parts.extend( + [ + ( + f'' + f"Privacy Guard averaged {100.0 * mean_turn_share:.2f}%" + ), + ( + f'of end-to-end turn time' + ), + ( + f'across {len(completed_turns)} ' + "completed turns" + ), + ] + ) + + parts.extend( + [ + ( + f'' + "Entities detected" + ), + ( + f'' + ), + ( + f'Prompt tokens · log scale' + ), + ( + f'' + f"Single fit across {len(measurements)} measurements: " + f"{fit.intercept_ms:.2f} + " + f"{fit.milliseconds_per_100k_tokens:.2f} ms per 100k tokens · " + f"R² {fit.r_squared:.3f}" + ), + ] + ) + for tick in (minimum_entities, 100, 200, 300, maximum_entities): + tick_x = _COLORBAR_X + _COLORBAR_WIDTH * ( + (tick - minimum_entities) / (maximum_entities - minimum_entities) + ) + parts.append( + f'' + ) + parts.append( + f'{tick}' + ) + parts.append("") + return "\n".join(parts) + "\n" + + +def _x(value: float) -> float: + fraction = (math.log10(value) - math.log10(_X_MIN)) / ( + math.log10(_X_MAX) - math.log10(_X_MIN) + ) + return _MARGIN_LEFT + fraction * (_WIDTH - _MARGIN_LEFT - _MARGIN_RIGHT) + + +def _y(value: float) -> float: + fraction = (math.log10(value) - math.log10(_Y_MIN)) / ( + math.log10(_Y_MAX) - math.log10(_Y_MIN) + ) + return ( + _HEIGHT - _MARGIN_BOTTOM - fraction * (_HEIGHT - _MARGIN_TOP - _MARGIN_BOTTOM) + ) + + +def _format_tokens(value: int) -> str: + if value >= 1_000_000: + return f"{value / 1_000_000:.0f}M" + return f"{value // 1_000}k" + + +def _entity_color(value: int, *, minimum: int, maximum: int) -> str: + if maximum == minimum: + return _rgb_hex(_ENTITY_COLORS[len(_ENTITY_COLORS) // 2][1]) + fraction = (value - minimum) / (maximum - minimum) + for (left_position, left_color), ( + right_position, + right_color, + ) in zip(_ENTITY_COLORS, _ENTITY_COLORS[1:], strict=True): + if fraction <= right_position: + segment = (fraction - left_position) / (right_position - left_position) + color = ( + round(left_color[0] + segment * (right_color[0] - left_color[0])), + round(left_color[1] + segment * (right_color[1] - left_color[1])), + round(left_color[2] + segment * (right_color[2] - left_color[2])), + ) + return _rgb_hex(color) + return _rgb_hex(_ENTITY_COLORS[-1][1]) + + +def _rgb_hex(color: tuple[int, int, int]) -> str: + return "#" + "".join(f"{channel:02x}" for channel in color) + + +if __name__ == "__main__": + main()