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 @@
+
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'")
+ 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()