aicordon 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aicordon/__init__.py +6 -0
- aicordon/__main__.py +9 -0
- aicordon/cli/__init__.py +0 -0
- aicordon/cli/bench.py +341 -0
- aicordon/cli/main.py +555 -0
- aicordon/cli/render.py +276 -0
- aicordon/cli/report.py +156 -0
- aicordon/cli/umbrella.py +260 -0
- aicordon/core/__init__.py +0 -0
- aicordon/core/engine.py +157 -0
- aicordon/core/model.py +234 -0
- aicordon/core/product.py +79 -0
- aicordon/picket/__init__.py +106 -0
- aicordon/picket/data/engine_v1_20260731_b1.bin +0 -0
- aicordon/picket/detector.py +155 -0
- aicordon/picket/rule/__init__.py +6 -0
- aicordon/picket/rule/ac.py +173 -0
- aicordon/picket/rule/compiled.py +333 -0
- aicordon/picket/rule/ent.py +137 -0
- aicordon/picket/rule/frag.py +274 -0
- aicordon/picket/rule/l2.py +340 -0
- aicordon/picket/rule/norm.py +173 -0
- aicordon/picket/rule/scan.py +388 -0
- aicordon/picket/rule/threats.py +272 -0
- aicordon/py.typed +0 -0
- aicordon/selftest.py +516 -0
- aicordon-0.1.0.dist-info/METADATA +537 -0
- aicordon-0.1.0.dist-info/RECORD +32 -0
- aicordon-0.1.0.dist-info/WHEEL +4 -0
- aicordon-0.1.0.dist-info/entry_points.txt +2 -0
- aicordon-0.1.0.dist-info/licenses/LICENSE +202 -0
- aicordon-0.1.0.dist-info/licenses/NOTICE +14 -0
aicordon/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""AI Cordon — the shared core of the company's products: finding model, detector contract, CLI.
|
|
2
|
+
|
|
3
|
+
There is no detector here. Detectors live in subpackages of their own (`aicordon.picket`, `aicordon.intent`) and present
|
|
4
|
+
one and the same `core.engine` contract; the core does not know them and must not — which is
|
|
5
|
+
exactly why the command layer of the products is shared rather than "similar".
|
|
6
|
+
"""
|
aicordon/__main__.py
ADDED
aicordon/cli/__init__.py
ADDED
|
File without changes
|
aicordon/cli/bench.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Measuring the detector on YOUR documents: the numbers and the chart, without dependencies.
|
|
2
|
+
|
|
3
|
+
Why it is a command and not a page in the README. Every figure we publish was measured on our
|
|
4
|
+
machine and our corpora, and both differ from yours: the constant scales with the CPU, and how much
|
|
5
|
+
a document costs depends on what is written in it. A number you can reproduce where it will run is
|
|
6
|
+
worth more than a number you have to believe, so the tool carries the measurement with it.
|
|
7
|
+
|
|
8
|
+
What it reports is what we ourselves look at:
|
|
9
|
+
|
|
10
|
+
per document median, P10-P90, P99, max — the spread matters more than the average
|
|
11
|
+
per 1000 chars slope and intercept of the cost model, each with an interval
|
|
12
|
+
throughput documents and characters per second, and the whole run
|
|
13
|
+
flagged share how often it fires HERE — not an error rate, see below
|
|
14
|
+
|
|
15
|
+
The intervals are bootstrapped over documents rather than taken from a formula: the spread grows
|
|
16
|
+
with document size, so the constant-variance assumption behind the textbook interval is false here.
|
|
17
|
+
Two thousand resamples, a fixed seed, plain `random` — nothing to install.
|
|
18
|
+
|
|
19
|
+
The chart is written as SVG, which is text: no plotting library, no fonts to embed, and it opens in
|
|
20
|
+
any browser. That is not asceticism — a detector that must be installable inside somebody else's
|
|
21
|
+
dependency tree cannot bring matplotlib along for a chart.
|
|
22
|
+
|
|
23
|
+
**The flagged share is not a false-positive rate.** This code has no idea whether your documents
|
|
24
|
+
contain injections. It reports how often the detector fired here; calling that an error rate would
|
|
25
|
+
require labels, and labels are exactly what a corpus of your own documents does not come with.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import random
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from typing import Iterable
|
|
34
|
+
|
|
35
|
+
BOOT = 2000
|
|
36
|
+
SEED = 20260801
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def measure(det, docs: Iterable, repeat: int = 1, progress=None) -> list[dict]:
|
|
40
|
+
"""One row per document: its size, the median of `repeat` timings, and whether it fired.
|
|
41
|
+
|
|
42
|
+
The median of repeats and not the first timing: the first touch of a document pays for the file
|
|
43
|
+
cache and for whatever else the machine was doing, and on short documents that noise is larger
|
|
44
|
+
than the thing being measured.
|
|
45
|
+
"""
|
|
46
|
+
rows: list[dict] = []
|
|
47
|
+
for i, doc in enumerate(docs, 1):
|
|
48
|
+
ts = []
|
|
49
|
+
flagged = False
|
|
50
|
+
for _ in range(max(1, repeat)):
|
|
51
|
+
t = time.perf_counter()
|
|
52
|
+
for rep in det.reports([doc]):
|
|
53
|
+
flagged = rep.flagged
|
|
54
|
+
ts.append((time.perf_counter() - t) * 1000)
|
|
55
|
+
ts.sort()
|
|
56
|
+
rows.append({"id": doc.id, "size": len(doc.text), "ms": ts[len(ts) // 2],
|
|
57
|
+
"flagged": flagged})
|
|
58
|
+
if progress:
|
|
59
|
+
progress(i)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Progress:
|
|
64
|
+
"""A progress line for a run that takes minutes. Standard library, no dependency.
|
|
65
|
+
|
|
66
|
+
Two shapes for two readers. On a terminal it is ONE line, rewritten in place with `\r`, with a
|
|
67
|
+
bar, a rate and a forecast — a person watching wants to know whether to wait or to leave. Piped
|
|
68
|
+
into a file or a CI log there is no cursor to move, so it degrades to a line every so often;
|
|
69
|
+
a bar written with `\r` into a log file produces a single unreadable line kilometres long.
|
|
70
|
+
|
|
71
|
+
Always on stderr: the report goes to stdout and has to stay pipeable.
|
|
72
|
+
|
|
73
|
+
The forecast is deliberately naive — elapsed per document times what is left. The alternative,
|
|
74
|
+
a moving average, is worse here: document costs differ by an order of magnitude, and a rate
|
|
75
|
+
computed over the last few makes the estimate jump.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
WIDTH = 28
|
|
79
|
+
|
|
80
|
+
def __init__(self, total: int | None = None, every: int = 50) -> None:
|
|
81
|
+
self.total = total if total and total > 0 else None
|
|
82
|
+
self.every = max(1, every)
|
|
83
|
+
self.tty = bool(getattr(sys.stderr, "isatty", lambda: False)())
|
|
84
|
+
self.t0 = time.perf_counter()
|
|
85
|
+
self.n = 0
|
|
86
|
+
|
|
87
|
+
def tick(self, n: int) -> None:
|
|
88
|
+
self.n = n
|
|
89
|
+
if not self.tty:
|
|
90
|
+
if n % max(self.every, 50) == 0:
|
|
91
|
+
print(f" {n}{'/' + str(self.total) if self.total else ''}…",
|
|
92
|
+
file=sys.stderr, flush=True)
|
|
93
|
+
return
|
|
94
|
+
if n % self.every and n != self.total:
|
|
95
|
+
return
|
|
96
|
+
done = time.perf_counter() - self.t0
|
|
97
|
+
rate = n / done if done else 0
|
|
98
|
+
if self.total:
|
|
99
|
+
filled = int(self.WIDTH * n / self.total)
|
|
100
|
+
bar = "█" * filled + "·" * (self.WIDTH - filled)
|
|
101
|
+
left = (self.total - n) / rate if rate else 0
|
|
102
|
+
tail = f" {n}/{self.total} {rate:.0f}/s {_clock(left)} left"
|
|
103
|
+
else:
|
|
104
|
+
bar = "·" * self.WIDTH
|
|
105
|
+
tail = f" {n} {rate:.0f}/s {_clock(done)} elapsed"
|
|
106
|
+
print(f"\r {bar}{tail} ", end="", file=sys.stderr, flush=True)
|
|
107
|
+
|
|
108
|
+
def done(self) -> None:
|
|
109
|
+
"""Clears the line so the report starts on a clean one."""
|
|
110
|
+
if self.tty:
|
|
111
|
+
print("\r" + " " * (self.WIDTH + 44) + "\r", end="", file=sys.stderr, flush=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clock(seconds: float) -> str:
|
|
115
|
+
seconds = max(0, int(seconds))
|
|
116
|
+
if seconds < 60:
|
|
117
|
+
return f"{seconds}s"
|
|
118
|
+
if seconds < 3600:
|
|
119
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
120
|
+
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _quantile(sorted_vals: list[float], p: float) -> float:
|
|
124
|
+
if not sorted_vals:
|
|
125
|
+
return 0.0
|
|
126
|
+
return sorted_vals[min(len(sorted_vals) - 1, int(len(sorted_vals) * p))]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _fit(xs: list[float], ys: list[float]) -> tuple[float, float]:
|
|
130
|
+
"""Least squares with an intercept, written out because there is no numpy here."""
|
|
131
|
+
n = len(xs)
|
|
132
|
+
if n < 2:
|
|
133
|
+
return (ys[0] if ys else 0.0), 0.0
|
|
134
|
+
mx = sum(xs) / n
|
|
135
|
+
my = sum(ys) / n
|
|
136
|
+
sxx = sum((x - mx) ** 2 for x in xs)
|
|
137
|
+
if sxx == 0:
|
|
138
|
+
return my, 0.0
|
|
139
|
+
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx
|
|
140
|
+
return my - slope * mx, slope
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _boot_fit(xs: list[float], ys: list[float], n: int = BOOT) -> dict:
|
|
144
|
+
"""Intercept and slope, each with half the 95% interval — resampling documents, not residuals."""
|
|
145
|
+
a0, b0 = _fit(xs, ys)
|
|
146
|
+
rnd = random.Random(SEED)
|
|
147
|
+
N = len(xs)
|
|
148
|
+
if N < 8:
|
|
149
|
+
return {"intercept": a0, "intercept_pm": None, "slope": b0, "slope_pm": None}
|
|
150
|
+
A, B = [], []
|
|
151
|
+
for _ in range(n):
|
|
152
|
+
idx = [rnd.randrange(N) for _ in range(N)]
|
|
153
|
+
a, b = _fit([xs[i] for i in idx], [ys[i] for i in idx])
|
|
154
|
+
A.append(a)
|
|
155
|
+
B.append(b)
|
|
156
|
+
A.sort()
|
|
157
|
+
B.sort()
|
|
158
|
+
|
|
159
|
+
def pm(vals: list[float], point: float) -> float:
|
|
160
|
+
lo = vals[int(0.025 * len(vals))]
|
|
161
|
+
hi = vals[min(len(vals) - 1, int(0.975 * len(vals)))]
|
|
162
|
+
return max(point - lo, hi - point)
|
|
163
|
+
|
|
164
|
+
return {"intercept": a0, "intercept_pm": pm(A, a0), "slope": b0, "slope_pm": pm(B, b0)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def summarize(rows: list[dict], load_ms: float, engine: str, version: str) -> dict:
|
|
168
|
+
"""Every number the report carries. Sizes are in characters, times in milliseconds."""
|
|
169
|
+
times = sorted(r["ms"] for r in rows)
|
|
170
|
+
sizes = [float(r["size"]) for r in rows]
|
|
171
|
+
total_ms = sum(times)
|
|
172
|
+
total_chars = sum(sizes)
|
|
173
|
+
fit = _boot_fit(sizes, [r["ms"] for r in rows])
|
|
174
|
+
return {
|
|
175
|
+
"engine": engine,
|
|
176
|
+
"engine_version": version,
|
|
177
|
+
"documents": len(rows),
|
|
178
|
+
# NOT the startup of the command: this is the base coming up INSIDE an already running
|
|
179
|
+
# interpreter. The process as a whole costs several times more — interpreter, imports and
|
|
180
|
+
# this. Two different things under one word is how a README ends up arguing with its own
|
|
181
|
+
# example output, which is exactly what happened before this was renamed.
|
|
182
|
+
"base_load_ms": round(load_ms, 1),
|
|
183
|
+
"total_s": round(total_ms / 1000, 3),
|
|
184
|
+
"chars_total": int(total_chars),
|
|
185
|
+
"size": {
|
|
186
|
+
"median": int(_quantile(sorted(sizes), 0.5)),
|
|
187
|
+
"p10": int(_quantile(sorted(sizes), 0.10)),
|
|
188
|
+
"p90": int(_quantile(sorted(sizes), 0.90)),
|
|
189
|
+
"max": int(max(sizes)) if sizes else 0,
|
|
190
|
+
},
|
|
191
|
+
"ms_per_doc": {
|
|
192
|
+
"median": round(_quantile(times, 0.5), 3),
|
|
193
|
+
"p10": round(_quantile(times, 0.10), 3),
|
|
194
|
+
"p90": round(_quantile(times, 0.90), 3),
|
|
195
|
+
"p99": round(_quantile(times, 0.99), 3),
|
|
196
|
+
"max": round(times[-1], 3) if times else 0.0,
|
|
197
|
+
},
|
|
198
|
+
# The model everything else is derived from: cost = intercept + slope * characters.
|
|
199
|
+
"cost_model": {
|
|
200
|
+
"per_1000_chars_ms": round(fit["slope"] * 1000, 3),
|
|
201
|
+
"per_1000_chars_pm": None if fit["slope_pm"] is None else round(fit["slope_pm"] * 1000, 3),
|
|
202
|
+
"per_document_ms": round(fit["intercept"], 3),
|
|
203
|
+
"per_document_pm": None if fit["intercept_pm"] is None else round(fit["intercept_pm"], 3),
|
|
204
|
+
"confidence": "95%",
|
|
205
|
+
"method": "bootstrap over documents, 2000 resamples, fixed seed",
|
|
206
|
+
},
|
|
207
|
+
"throughput": {
|
|
208
|
+
"documents_per_s": round(len(rows) / (total_ms / 1000), 1) if total_ms else None,
|
|
209
|
+
"chars_per_s": int(total_chars / (total_ms / 1000)) if total_ms else None,
|
|
210
|
+
},
|
|
211
|
+
# The documents that cost the most PER CHARACTER, not the longest ones. This is a diagnostic
|
|
212
|
+
# and it has earned its place: a rule that hits one slot thousands of times in a signature
|
|
213
|
+
# line once cost 688 ms per 1000 characters where the rest of the corpus cost 2, and the
|
|
214
|
+
# only way that was ever going to be noticed is a list like this one.
|
|
215
|
+
"slowest_per_char": [
|
|
216
|
+
{"id": r["id"], "size": r["size"], "ms": round(r["ms"], 2),
|
|
217
|
+
"ms_per_1000": round(r["ms"] / max(1, r["size"]) * 1000, 2)}
|
|
218
|
+
for r in sorted(rows, key=lambda r: -(r["ms"] / max(1, r["size"])))[:5]
|
|
219
|
+
],
|
|
220
|
+
# NOT an error rate: this code does not know what is in your documents. See the module note.
|
|
221
|
+
"flagged": {
|
|
222
|
+
"documents": sum(1 for r in rows if r["flagged"]),
|
|
223
|
+
"share": round(sum(1 for r in rows if r["flagged"]) / len(rows), 6) if rows else 0.0,
|
|
224
|
+
"note": "share of documents that fired here; not a false-positive rate — "
|
|
225
|
+
"these documents carry no labels",
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def lines(s: dict) -> list[str]:
|
|
231
|
+
"""The same numbers for a terminal, in the order they answer questions."""
|
|
232
|
+
cm = s["cost_model"]
|
|
233
|
+
fitted = cm["per_1000_chars_pm"] is not None
|
|
234
|
+
d = s["ms_per_doc"]
|
|
235
|
+
out = [
|
|
236
|
+
f" documents {s['documents']}, median length {s['size']['median']} characters "
|
|
237
|
+
f"(P10-P90 {s['size']['p10']}-{s['size']['p90']})",
|
|
238
|
+
f" per document median {d['median']} ms P10-P90 {d['p10']}-{d['p90']} "
|
|
239
|
+
f"P99 {d['p99']} max {d['max']}",
|
|
240
|
+
# The model is printed only when it was actually fitted. On a handful of documents the
|
|
241
|
+
# numbers still come out, and printing them with a "(95%)" beside them would dress a guess
|
|
242
|
+
# up as a measurement — the one thing this command exists not to do.
|
|
243
|
+
(f" cost model {cm['per_1000_chars_ms']} ± {cm['per_1000_chars_pm']} ms per 1000 "
|
|
244
|
+
f"characters, plus {cm['per_document_ms']} ± {cm['per_document_pm']} ms per document "
|
|
245
|
+
f"({cm['confidence']})") if fitted else
|
|
246
|
+
f" cost model not fitted: {s['documents']} document(s) is too few to separate the "
|
|
247
|
+
f"per-character cost from the per-document one",
|
|
248
|
+
f" throughput {s['throughput']['documents_per_s']} documents/s, "
|
|
249
|
+
f"{s['throughput']['chars_per_s']} characters/s",
|
|
250
|
+
f" base load {s['base_load_ms']} ms, once per process "
|
|
251
|
+
f"(the whole command costs more: interpreter and imports on top)",
|
|
252
|
+
f" whole run {s['total_s']} s",
|
|
253
|
+
f" dearest doc {s['slowest_per_char'][0]['ms_per_1000']} ms per 1000 characters "
|
|
254
|
+
f"({s['slowest_per_char'][0]['size']} chars) — against a median of "
|
|
255
|
+
f"{s['ms_per_doc']['median']} ms per document" if s.get("slowest_per_char") else "",
|
|
256
|
+
f" fired on {s['flagged']['documents']} of {s['documents']} documents "
|
|
257
|
+
f"({s['flagged']['share']:.2%}) — not an error rate, these documents carry no labels",
|
|
258
|
+
]
|
|
259
|
+
return [x for x in out if x]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- the chart -----------------------------------------------------------------------------------
|
|
263
|
+
#
|
|
264
|
+
# Hand-written SVG, and it has to stay hand-written: the package declares no dependencies, and a
|
|
265
|
+
# chart is not a reason to break that. Everything here is arithmetic and string formatting.
|
|
266
|
+
|
|
267
|
+
W, H, PAD_L, PAD_B, PAD_T, PAD_R = 760, 420, 64, 48, 56, 20
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _nice(v: float) -> float:
|
|
271
|
+
"""A round number at or above `v`, for an axis a human can read."""
|
|
272
|
+
if v <= 0:
|
|
273
|
+
return 1.0
|
|
274
|
+
exp = math.floor(math.log10(v))
|
|
275
|
+
base = 10 ** exp
|
|
276
|
+
for m in (1, 2, 2.5, 5, 10):
|
|
277
|
+
if m * base >= v:
|
|
278
|
+
return m * base
|
|
279
|
+
return 10 * base
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def svg(rows: list[dict], s: dict, title: str = "Time to check one document") -> str:
|
|
283
|
+
xs = [r["size"] for r in rows] or [1]
|
|
284
|
+
ys = [r["ms"] for r in rows] or [0.0]
|
|
285
|
+
xmax = _nice(max(xs))
|
|
286
|
+
ymax = _nice(max(ys))
|
|
287
|
+
plot_w = W - PAD_L - PAD_R
|
|
288
|
+
plot_h = H - PAD_T - PAD_B
|
|
289
|
+
|
|
290
|
+
def px(x: float) -> float:
|
|
291
|
+
return PAD_L + plot_w * (x / xmax)
|
|
292
|
+
|
|
293
|
+
def py(y: float) -> float:
|
|
294
|
+
return PAD_T + plot_h * (1 - y / ymax)
|
|
295
|
+
|
|
296
|
+
cm = s["cost_model"]
|
|
297
|
+
a = cm["per_document_ms"]
|
|
298
|
+
b = cm["per_1000_chars_ms"] / 1000.0
|
|
299
|
+
|
|
300
|
+
dots = "".join(
|
|
301
|
+
f'<circle cx="{px(r["size"]):.1f}" cy="{py(r["ms"]):.1f}" r="2.6" '
|
|
302
|
+
f'class="{"hit" if r["flagged"] else "dot"}"/>'
|
|
303
|
+
for r in rows)
|
|
304
|
+
grid = "".join(
|
|
305
|
+
f'<line x1="{PAD_L}" y1="{py(ymax * k / 4):.1f}" x2="{W - PAD_R}" y2="{py(ymax * k / 4):.1f}"'
|
|
306
|
+
f' class="grid"/><text x="{PAD_L - 8}" y="{py(ymax * k / 4) + 4:.1f}" class="tick" '
|
|
307
|
+
f'text-anchor="end">{ymax * k / 4:g}</text>'
|
|
308
|
+
for k in range(5))
|
|
309
|
+
xticks = "".join(
|
|
310
|
+
f'<text x="{px(xmax * k / 4):.1f}" y="{H - PAD_B + 20}" class="tick" '
|
|
311
|
+
f'text-anchor="middle">{int(xmax * k / 4)}</text>'
|
|
312
|
+
for k in range(5))
|
|
313
|
+
pm = f" ± {cm['per_1000_chars_pm']}" if cm["per_1000_chars_pm"] is not None else ""
|
|
314
|
+
|
|
315
|
+
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" \
|
|
316
|
+
height="{H}" font-family="system-ui, sans-serif">
|
|
317
|
+
<style>
|
|
318
|
+
.bg {{ fill: #fbfbf9; }} .ink {{ fill: #1a1a19; }} .muted {{ fill: #6b6a66; }}
|
|
319
|
+
.grid {{ stroke: #e6e4de; stroke-width: 1; }} .tick {{ fill: #8a8880; font-size: 11px; }}
|
|
320
|
+
.dot {{ fill: #3f7fd0; fill-opacity: .45; }} .hit {{ fill: #d9663f; fill-opacity: .55; }}
|
|
321
|
+
.fit {{ stroke: #6b6a66; stroke-width: 1.6; stroke-dasharray: 6 4; fill: none; }}
|
|
322
|
+
@media (prefers-color-scheme: dark) {{
|
|
323
|
+
.bg {{ fill: #1a1a19; }} .ink {{ fill: #ffffff; }} .muted {{ fill: #898781; }}
|
|
324
|
+
.grid {{ stroke: #333230; }} .tick {{ fill: #6b6a66; }}
|
|
325
|
+
}}
|
|
326
|
+
</style>
|
|
327
|
+
<rect class="bg" width="{W}" height="{H}"/>
|
|
328
|
+
<text x="16" y="26" class="ink" font-size="15" font-weight="600">{title}</text>
|
|
329
|
+
<text x="16" y="44" class="muted" font-size="11.5">{s['documents']} documents · \
|
|
330
|
+
{cm['per_1000_chars_ms']}{pm} ms per 1000 characters ({cm['confidence']}) · \
|
|
331
|
+
{s['engine']} {s['engine_version']}</text>
|
|
332
|
+
{grid}{xticks}
|
|
333
|
+
<line class="fit" x1="{px(0):.1f}" y1="{py(a):.1f}" x2="{px(xmax):.1f}" \
|
|
334
|
+
y2="{py(min(ymax, a + b * xmax)):.1f}"/>
|
|
335
|
+
{dots}
|
|
336
|
+
<text x="{PAD_L + plot_w / 2:.0f}" y="{H - 8}" class="muted" font-size="11" \
|
|
337
|
+
text-anchor="middle">document size, characters</text>
|
|
338
|
+
<text x="14" y="{PAD_T + plot_h / 2:.0f}" class="muted" font-size="11" \
|
|
339
|
+
transform="rotate(-90 14 {PAD_T + plot_h / 2:.0f})" text-anchor="middle">milliseconds</text>
|
|
340
|
+
</svg>
|
|
341
|
+
"""
|