From 5a4da12bdafae27fe5b410cb05ae4f21101adbb9 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:38:56 -0300 Subject: [PATCH 001/306] Fail only on mature rules and levels by default A rule and level is mature when its findings were right at least 80% of the time on the 25 projects JevGate was never tuned on, over at least 20 hand labels. The default gate level is now `mature`, which fails only on those; every other finding is reported without failing the check. Any level set in jevgate.toml or with --fail-on replaces it as it says, and `mature` can be set by name. Measured from the corpus labels joined to 0.25.0's findings (replayed from the answer cache): function-simplification reviews (20 of 23) and agent-context considers (22 of 24) are mature. On the unseen projects' full runs the default gate failed on 122 findings, 57% of the labeled ones right, and on 17 of 22 projects; it now fails on 23, 87% right, and on 9. The concern probability does not separate right from wrong reviews there (55%, 46%, 56% and 61% across four bands), so the table in src/maturity.rs decides. Each finding records how the gate counted it (`gate`: fails, measuring or advisory), so the JSON report, annotations, SARIF, GitLab, the HTML report and the MCP tool agree, and the report names what `mature` stands for in `fail_on_mature`. The agent text marks findings that fail the gate and lists them first wherever a list is capped, and says when reviews did not fail it because their rules are still being measured. `jevgate rules` shows the levels that fail by default and each rule's precision on unseen projects. `jevgate init` writes the groups as commented examples, since `maintainability = "review"` would opt every maintainability review back in. --- CHANGELOG.md | 26 ++++ README.md | 2 +- docs/classification-cascade.md | 9 +- jevgate.schema.json | 6 +- site/generate.py | 28 +++- site/src/ci.md | 4 +- site/src/coding-agents.md | 2 +- site/src/configuration.md | 16 ++- site/src/how-it-works.md | 9 +- site/src/output.md | 6 +- site/src/stability.md | 1 + site/src/troubleshooting.md | 4 + site/src/what-it-finds.md | 2 +- src/catalog.rs | 60 +++++++-- src/changes.rs | 1 + src/config.rs | 54 +++++++- src/config_schema.rs | 9 +- src/evaluate.rs | 1 + src/gate.rs | 57 +++++--- src/github.rs | 88 +++++++++--- src/gitlab.rs | 42 ++++-- src/html_report.rs | 16 ++- src/init.rs | 78 +++++++---- src/main.rs | 5 +- src/maturity.rs | 237 +++++++++++++++++++++++++++++++++ src/mcp.rs | 41 +++++- src/options/commands.rs | 16 ++- src/options/mod.rs | 47 +++++-- src/output.rs | 127 ++++++++++++++---- src/report.html | 9 +- src/sarif.rs | 64 +++++---- src/schema/report.rs | 29 ++++ src/tests/gating.rs | 149 +++++++++++++++++++-- src/tests/mod.rs | 25 +++- src/units/compose/comments.rs | 1 + src/units/compose/mod.rs | 1 + src/units/compose/redundant.rs | 1 + tests/cli/rules.rs | 41 +++++- 38 files changed, 1113 insertions(+), 201 deletions(-) create mode 100644 src/maturity.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 619ae9c..b39abd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- By default, only the rules and levels measured right on projects JevGate was never tuned on fail the check. The default gate level is now `mature`: a rule's reviews or considers fail the check when at least 80% of them were right on the 25 projects never used for tuning (11 held out, 14 fresh), over at least 20 findings labeled by hand from the code. Every other finding is still reported, marked as still being measured, and the check passes. Two levels are mature: function-simplification reviews (20 of 23 right, 87%) and agent-context considers (22 of 24, 92%; the documentation rules are opt-in, and the 22 right ones come from 4 of the maintainer's own repositories). On the unseen projects' full runs, the default gate failed on 122 findings, 57% of the labeled ones right (64% leaving the 13 debatable ones out), and on 17 of 22 projects; it now fails on 23, 87% right, and on 9 projects. On the 72 projects used for tuning: 468 findings at 73% to 95 at 83%, and 61 projects to 28. The 49 right reviews on unseen projects that no longer fail the check are still reported. The concern probability could not do this: unseen reviews were right 55%, 46%, 56% and 61% of the time with a probability below 0.90, below 0.95, below 0.98 and above. The labels were made on 0.24.1's findings and joined to 0.25.0's, replayed from the answer cache; 0.25.0 had turned 17 labeled security findings into notes, one on an unseen project, none in a mature level. Right / labeled on unseen and tuned projects, a debatable label counting as not right: + + | Rule | Reviews, unseen | Reviews, tuned | Considers, unseen | Considers, tuned | + |---|---|---|---|---| + | File organization | 2/5 | 15/30 | 17/29 | 19/43 | + | Function simplification | **20/23 (87%)** | 57/69 | 85/126 (67%) | 147/197 | + | Shared logic | 46/85 (54%) | 181/240 (75%) | 85/158 (54%) | 146/244 | + | Hardcoded values | 1/8 | 15/28 | 5/29 | 32/57 | + | Injection | 3/4 | 81/96 | 5/13 | 27/47 | + | Sensitive data | 10/24 | 41/64 | 0/5 | 12/15 | + | Unsafe settings | 2/4 | 53/72 | 0/4 | 16/21 | + | Access control | - | 2/5 | - | 5/14 | + | Workflows | 1/1 | 0/1 | - | - | + | Test value | 3/5 | 5/11 | 1/2 | 20/27 | + | Test redundancy | 1/1 | 4/4 | 27/45 (60%) | 27/34 | + | Agent context | - | - | **22/24 (92%)** | 64/68 | + | Large docs | - | - | 1/1 | 1/5 | + | Staleness | - | - | 2/2 | 14/15 | + | Duplication | - | - | 3/20 | 5/22 | + | Code comments | - | - | 39/72 (54%) | 97/150 | + + - Any level you set replaces the default exactly as it says: `fail_on = ["review"]` or `--fail-on review` fails on every review, as before; `--fail-on security=consider` sets one group and leaves the others at `mature`, which can also be set by name (`security = "mature"` in `[rules]`). Undecided answers never fail the check under `mature`. + - A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`, which keeps every review of those groups failing the check; delete the two lines for the new default. `jevgate init` now writes each group as a commented example. + - The output says what fails. The agent text marks each finding that fails the gate `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured and how to make them fail it. A GitHub warning, a SARIF result and a GitLab issue for such a finding say so, with how often its rule and level were right. The JSON report records how the gate counted each new finding as `gate` (`fails`, `measuring` or `advisory`), and `fail_on_mature` says what `mature` stands for among the selected rules; the HTML report and the MCP server's `jevgate_findings` show both. + - `jevgate rules` shows the levels that fail by default and how often each rule's reviews and considers were right on unseen projects, with the number labeled; `--format json` adds each level's labels on unseen and tuned projects as `maturity`. + ## [0.25.0] - 2026-09-27 Fixes from running JevGate on widely used projects under daily development (rtk, headroom, paperclip, hermes-agent, cc-switch, freellmapi, herdr, multica, OmniRoute, dify, openclaw, n8n), each checked against the code. diff --git a/README.md b/README.md index 202b125..2537c70 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ It reviews only the changed files, annotates each finding on its line and writes | 1 | Gate failed | | 2 | Run incomplete, invalid configuration or invalid usage | -Findings are `review` (act on it), `consider` (worth a look) or `note` (optional). A file whose answers stay undecided is `uncertain`, never hidden or counted as clear. `--fail-on` and `jevgate.toml` set what fails the gate, per rule and per path. `jevgate baseline` accepts today's findings, and a `jevgate: allow(RULE) reason` comment accepts one where it is. +Findings are `review` (act on it), `consider` (worth a look) or `note` (optional). A file whose answers stay undecided is `uncertain`, never hidden or counted as clear. By default only the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on fail the gate (`jevgate rules` shows them); the other findings are reported without failing it. `--fail-on` and `jevgate.toml` set what fails the gate, per rule and per path. `jevgate baseline` accepts today's findings, and a `jevgate: allow(RULE) reason` comment accepts one where it is. ## Privacy and cost diff --git a/docs/classification-cascade.md b/docs/classification-cascade.md index abd310e..9fd2356 100644 --- a/docs/classification-cascade.md +++ b/docs/classification-cascade.md @@ -692,8 +692,13 @@ signatures, or one candidate pair. rather than splitting it, so a consider left naming no group is a note and a review says to split the whole file. 6. **Gate.** `--fail-on`, `[[scope]]` levels per path and the baseline act on - composed findings only. Baseline entries can carry a reason (`intended`, - `later`, `wrong`) that survives rewrites; `baseline stats` counts them. + composed findings only. The default level, `mature`, fails only on the + rules and levels whose findings were right at least 80% of the time on + projects never used for tuning, over at least 20 hand labels + (`maturity::TABLE`); a probability says how sure an answer is, not how + often such findings are right. Baseline entries can carry a reason + (`intended`, `later`, `wrong`) that survives rewrites; `baseline stats` + counts them. ## Constraints diff --git a/jevgate.schema.json b/jevgate.schema.json index 9042d77..9d9afa4 100644 --- a/jevgate.schema.json +++ b/jevgate.schema.json @@ -6,6 +6,7 @@ "enum": [ "review", "consider", + "mature", "uncertain", "report", "none", @@ -18,6 +19,7 @@ "enum": [ "review", "consider", + "mature", "uncertain", "report", "none", @@ -168,6 +170,7 @@ "enum": [ "review", "consider", + "mature", "uncertain", "report", "none" @@ -278,11 +281,12 @@ "type": "array" }, "fail_on": { - "description": "The level for rules without their own, like `--fail-on`. Default: [\"review\"].", + "description": "The level for rules without their own, like `--fail-on`. Default: [\"mature\"], which fails only on the levels of a rule measured right at least 80% of the time on projects JevGate was never tuned on; `jevgate rules` shows them.", "items": { "enum": [ "review", "consider", + "mature", "uncertain", "report", "none" diff --git a/site/generate.py b/site/generate.py index 5d47ade..65a5430 100644 --- a/site/generate.py +++ b/site/generate.py @@ -36,14 +36,22 @@ def rules_page(binary): "its key or its group anywhere a rule is accepted: `--rule`, `--skip-rule`,", "`--fail-on TARGET=LEVEL`, `[rules]`, `[[scope]]` and `jevgate: allow(…)` comments.", "", - "| Rule | Key | Default | Question |", - "|---|---|---|---|", + "*Fails by default* names the levels that fail the check under the default gate level,", + "`mature`: those right at least 80% of the time over at least 20 findings labeled by hand on", + "projects JevGate was never tuned on. The other findings are reported without failing it.", + "*Reviews right* and *considers right* give the share of labeled findings that were right on", + "those projects, and how many were labeled; a debatable one counts as not right.", + "", + "| Rule | Key | Default | Fails by default | Reviews right | Considers right | Question |", + "|---|---|---|---|---|---|---|", ] for rule in rules: anchor = rule["id"].replace("/", "-") default = "yes" if rule["default_enabled"] else "opt-in" + maturity = rule["maturity"] lines.append( - f"| [`{rule['id']}`](#{anchor}) | `{rule['key']}` | {default} | {cell(rule['inspection'])} |" + f"| [`{rule['id']}`](#{anchor}) | `{rule['key']}` | {default} | {blocks(maturity)}" + f" | {right(maturity, 'review')} | {right(maturity, 'consider')} | {cell(rule['inspection'])} |" ) group = None for rule in rules: @@ -78,6 +86,20 @@ def rules_page(binary): return "\n".join(lines) + "\n" +def blocks(maturity): + """The levels that fail the default gate, or "no".""" + return ", ".join(level for level in ("review", "consider") if maturity.get(level, {}).get("mature")) or "no" + + +def right(maturity, level): + """"87% of 23": labeled findings of a level right on unseen projects, or "-".""" + unseen = maturity.get(level, {}).get("unseen") + if not unseen or not unseen["labeled"]: + return "-" + percent = (200 * unseen["right"] + unseen["labeled"]) // (2 * unseen["labeled"]) # half up, as JevGate rounds + return f"{percent}% of {unseen['labeled']}" + + def configuration_page(schema_path): schema = json.loads(Path(schema_path).read_text()) lines = [ diff --git a/site/src/ci.md b/site/src/ci.md index 90e59b7..b1d2a6a 100644 --- a/site/src/ci.md +++ b/site/src/ci.md @@ -22,11 +22,11 @@ jobs: The action installs a checked release binary, keeps `.jevgate/cache` in the Actions cache and runs `jevgate check --base --format github`; `args` passes more flags, such as `--rule security`. It runs on Linux, macOS and Windows runners. -`--format github` annotates the changed lines with each finding. A finding that fails the gate is an error; the others are warnings. A Markdown table goes to the job summary, and the usual text goes to the log. The full JSON report is always at `.jevgate/latest.json` if you want to keep it as an artifact. +`--format github` annotates the changed lines with each finding. A finding that fails the gate is an error; the others are warnings, and a warning whose rule and level are still being measured says so, with how often such findings were right. A Markdown table goes to the job summary, and the usual text goes to the log. The full JSON report is always at `.jevgate/latest.json` if you want to keep it as an artifact. - **Changed files only:** `--base` reviews what changed since the fork point with that revision, the same files a pull request diff shows, plus uncommitted and untracked files. It needs the history, so check out with `fetch-depth: 0`. When no supported file changed, the run passes without any request. - **Cache:** answers are stored under a hash of the exact request: source, questions and model. Restoring an older cache is always safe, and unchanged code costs nothing on the next run. -- **Advisory or blocking:** `fail_on = ["none"]` in `jevgate.toml` or `--fail-on none` reports findings without failing. A run that could not finish (missing key, provider rejection, request budget reached) still exits 2, so an outage never passes as a clean review. +- **Advisory or blocking:** by default only the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on fail the check ([what fails by default](configuration.md#what-fails-the-check-by-default)); the other findings are warnings. `fail_on = ["review"]` in `jevgate.toml` or `--fail-on review` fails on every review, and `fail_on = ["none"]` or `--fail-on none` reports findings without failing. A run that could not finish (missing key, provider rejection, request budget reached) still exits 2, so an outage never passes as a clean review. - **A policy the change cannot edit:** a pull request can edit `jevgate.toml`. To apply the reviewed policy of the base branch instead, read it with `--config`: ```sh diff --git a/site/src/coding-agents.md b/site/src/coding-agents.md index fefcddd..84a9767 100644 --- a/site/src/coding-agents.md +++ b/site/src/coding-agents.md @@ -15,7 +15,7 @@ for a `consider`, fix it or say why the code should stay as it is. | Exit code | Meaning for the agent | |---|---| -| 0 | The gate passed; `consider` findings may still be worth a look | +| 0 | The gate passed; `consider` findings, and reviews from rules still being measured, may still be worth fixing | | 1 | The gate failed: act on the findings listed | | 2 | The run could not finish (no key, provider rejection, request budget); report it, don't treat it as a pass | diff --git a/site/src/configuration.md b/site/src/configuration.md index e9358c3..6a68534 100644 --- a/site/src/configuration.md +++ b/site/src/configuration.md @@ -9,9 +9,9 @@ include_tests = true max_requests = 300 [rules] # a level per group or rule -maintainability = "review" # judge, and fail the gate on review findings +maintainability = "review" # judge every rule of the group, and fail on its reviews tests = "consider" -security = "consider" # opt-in group, enabled by naming it +security = "mature" # opt-in group, enabled by naming it; fails only on levels measured mature "maintainability/hardcoded-values" = "report" # judge but never fail; "off" skips it [[scope]] # levels for the files these paths match @@ -27,9 +27,9 @@ rules = { security = "consider" } # except these | `generated` | built-in names | Globs of generated files, which are skipped | | `tests` | built-in conventions | Globs of additional test files | | `context` | none | Files always sent as related evidence, like `--context` | -| `rules` | the `default` group | A list selects rules. A table gives each group or rule a level: `review`, `consider`, `uncertain`, `report` (judge, never fail) or `off` | +| `rules` | the `default` group | A list selects rules. A table gives each group or rule a level: `review`, `consider`, `mature`, `uncertain`, `report` (judge, never fail) or `off`; a level for a group judges every rule of it, opt-in ones included | | `[[scope]]` | none | `paths` (globs), with `fail_on` for every rule and `rules` for rules or groups, as above; `off` is not accepted (use `upload_deny`). The last scope that matches a file and addresses a rule wins; flags win over scopes | -| `fail_on` | `["review"]` | The level for rules without their own, like `--fail-on` | +| `fail_on` | `["mature"]` | The level for rules without their own, like `--fail-on` | | `include_tests` | `false` | Judge tests, like `--include-tests` | | `model` | `jev-1.13.0` | TypeSafe model; a pinned version keeps results repeatable | | `cache_ttl_secs` | `3600` | Cache lifetime for the `jev-latest` and `jev-preview` aliases; pinned versions never expire | @@ -40,4 +40,12 @@ rules = { security = "consider" } # except these Rules are named by ID (`maintainability/shared-logic`), key (`shared_logic`) or group (`maintainability`, `tests`, `security`, `documentation`, `default`, `all`). The same names work in `--rule`, `--skip-rule` and `--fail-on TARGET=LEVEL`, and the most specific entry wins. +## What fails the check by default + +The default level, `mature`, fails the check only on the rules and levels measured *mature*: their findings were right at least 80% of the time on projects JevGate was never tuned on, over at least 20 findings labeled by hand from the code. Today those are function-simplification reviews (20 of 23 right) and, when the documentation rules run, agent-context considers (22 of 24). Every other finding is reported and marked as still being measured, without failing the check. `jevgate rules` shows each rule's levels that fail by default and how often its reviews and considers were right. + +Any level you set replaces the default exactly as it says, for the rules and paths it addresses: `fail_on = ["review"]` (or `--fail-on review`) fails on every review, as releases before 0.26 did; `--fail-on security=consider` sets one group and leaves the others at `mature`; `mature` itself can be set, such as for one group after a stricter `fail_on`. A later release can mark more levels mature as labels accumulate, or fewer; set `fail_on` to keep a fixed policy. Undecided answers never fail the check under `mature`. + +A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`: those lines keep every review of the two groups failing the check. Delete them for the default gate. + The [configuration reference](reference/configuration.md) lists every key with its type, and the rule names and levels it accepts. diff --git a/site/src/how-it-works.md b/site/src/how-it-works.md index 5d85194..e0c0624 100644 --- a/site/src/how-it-works.md +++ b/site/src/how-it-works.md @@ -759,8 +759,13 @@ signatures, or one candidate pair. rather than splitting it, so a consider left naming no group is a note and a review says to split the whole file. 6. **Gate.** `--fail-on`, `[[scope]]` levels per path and the baseline act on - composed findings only. Baseline entries can carry a reason (`intended`, - `later`, `wrong`) that survives rewrites; `baseline stats` counts them. + composed findings only. The default level, `mature`, fails only on the + rules and levels whose findings were right at least 80% of the time on + projects never used for tuning, over at least 20 hand labels + (`maturity::TABLE`); a probability says how sure an answer is, not how + often such findings are right. Baseline entries can carry a reason + (`intended`, `later`, `wrong`) that survives rewrites; `baseline stats` + counts them. ### Constraints diff --git a/site/src/output.md b/site/src/output.md index 11e2382..a4a8f85 100644 --- a/site/src/output.md +++ b/site/src/output.md @@ -6,7 +6,7 @@ | `json` | The full report: every file, finding, raw answer and probability, gate and usage | | `jsonl` | One compact report per line; one per evaluation with `--watch` | | `github` | GitHub Actions annotations and job summary, then the agent text | -| `sarif` | A SARIF 2.1.0 log for [GitHub code scanning](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github) and other SARIF readers: the findings the annotations show, `error` when they fail the gate | +| `sarif` | A SARIF 2.1.0 log for [GitHub code scanning](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github) and other SARIF readers: the findings the annotations show, `error` when they fail the gate, with how the gate counted each as its `gate` property | | `gitlab` | A [GitLab Code Quality](https://docs.gitlab.com/ci/testing/code_quality/) report for merge requests: the same findings, `major` when they fail the gate and `minor` otherwise | Agent output is colored on a terminal; `--color never`, or `NO_COLOR` set to any value, turns it off, and `--color always` or `CLICOLOR_FORCE` turns it on for pipes and logs. @@ -19,7 +19,9 @@ Findings are `review` (act on it), `consider` (worth a look) or `note` (optional | 1 | Gate failed | | 2 | Run incomplete, invalid configuration or invalid usage | -`--fail-on review|consider|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. Baselined findings, findings allowed by a comment, and notes never fail it. +`--fail-on review|consider|mature|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. The default, `mature`, fails only on the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on; `jevgate rules` lists them, and [configuration](configuration.md#what-fails-the-check-by-default) explains it. Baselined findings, findings allowed by a comment, and notes never fail the gate. + +The agent text marks each finding that fails the gate with `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured. The JSON report records how the gate counted each new finding in its `gate` field: `fails`, `measuring` (reported without failing: the level is `mature` and its rule and level are still being measured) or `advisory` (the level in force does not count it, as `review` does not count a consider). `fail_on_mature` says what `mature` stands for among the selected rules. A single finding can also be accepted where it is, with a comment on its line or directly above it (doc comments and attributes may sit in between). The comment names a rule ID (`security/injection`), its name (`injection`), its key or a group, and needs a reason; without one it is ignored and the finding says so: diff --git a/site/src/stability.md b/site/src/stability.md index 3bbd637..67ee08c 100644 --- a/site/src/stability.md +++ b/site/src/stability.md @@ -24,6 +24,7 @@ Deprecated flags and keys keep working for at least one minor release, with a wa These are judgments or presentation, and any release can change them; the changelog says how: - **Which findings a rule reports**, their levels, wording, probabilities and next steps. Findings are model judgments composed by code, and improving them is most of what releases do. A rule's `version` changes when its questions or composition change, and its cached answers are asked again. +- **Which rules and levels fail the check by default.** The default level, `mature`, follows the labeled findings: a release marks a rule's reviews or considers mature once they are right at least 80% of the time on projects JevGate was never tuned on, over at least 20 labels, and can drop one that stops measuring up. The changelog gives the numbers. Set `fail_on`, or a level per rule, to keep a fixed policy. - **The agent text** (`--format agent`): it is written for people and coding agents to read. Scripts should read JSON. - **Request bodies and the answer cache**: the cache is safe to delete or restore at any version; unmatched entries are simply not used. - **The default model**: a release can pin a newer model version, which re-asks every unit once. Set `model` in `jevgate.toml` to keep one. diff --git a/site/src/troubleshooting.md b/site/src/troubleshooting.md index c0b1a62..afbba92 100644 --- a/site/src/troubleshooting.md +++ b/site/src/troubleshooting.md @@ -28,6 +28,10 @@ Rate limits, overload and server errors (HTTP 408, 429, 500, 502–504, 520–52 A file is `uncertain` when some of its answers stayed undecided after the follow-up questions. JevGate reports this instead of hiding it or counting the file as clear. `--verbose` lists each undecided unit and the question it stayed undecided on. It never fails the gate unless you ask for that with `--fail-on uncertain`. +## A review did not fail the check + +By default only the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on fail the check; `jevgate rules` shows which, and how often each rule's reviews and considers were right. The other findings are reported, and the output says their rules are still being measured. To fail on them, set a level: `--fail-on review` or `fail_on = ["review"]` for every rule, or `--fail-on maintainability/shared-logic=review` for one. + ## A finding is wrong Accept it with `jevgate baseline`, and record why with `jevgate baseline mark wrong PATH:LINE`; `jevgate baseline stats` counts each rule's mistaken findings. Reporting it with the [wrong finding template](https://github.com/Tech-Byte-Frontier/jevgate/issues/new?template=wrong_finding.yml), with the finding from `.jevgate/latest.json` and a small piece of the code, is how the rules improve. diff --git a/site/src/what-it-finds.md b/site/src/what-it-finds.md index d73a317..1657a10 100644 --- a/site/src/what-it-finds.md +++ b/site/src/what-it-finds.md @@ -39,4 +39,4 @@ The documentation rules read the instruction files that coding agents load (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and Claude, Cursor, Copilot, Windsurf, Cline, Kiro, Junie and Roo Code rules), even when hidden or gitignored. Each section is asked whether it only restates the stack, the manifest's commands, generic advice or a configured linter's rules, and whether text loaded in every session applies to only one directory. Project documentation in Markdown, MDX, reStructuredText or AsciiDoc of 300 or more lines is judged from its headings alone. Code finds staleness and duplication candidates: named paths or scripts that no longer exist, release tags, deleted files, and shared wording outside code examples. Jev then judges each candidate. Code comments and docstrings of application code are judged one at a time with the code they are about (the declaration they document, the lines below them or the line they end): whether they only repeat that code, hold sentences that add nothing, narrate an edit instead of the code as it is, or are code turned off. License headers, tool directives, type annotations, authorship tags and Sphinx version notes are left out; documentation that only repeats its declaration or says it at length (framework section banners included) is at most a `note`, as is every such comment in a project whose README says its code is written for learners; and the comments of one definition that span fewer than three lines in all are a `note`. Documentation findings are at most `consider`. The run also estimates the tokens each harness loads at session start; these estimates are evidence and never fail the gate. -`jevgate rules` prints every rule with its question and default; the [rules reference](reference/rules.md) lists them with what each one looks at. +`jevgate rules` prints every rule with its question, its default, and how often its reviews and considers were right on projects JevGate was never tuned on; by default only the levels right at least 80% of the time there fail the check. The [rules reference](reference/rules.md) lists them with what each one looks at. diff --git a/src/catalog.rs b/src/catalog.rs index 3432a6c..4b88c0a 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -416,37 +416,69 @@ pub fn policy() -> BTreeMap { } /// One line per rule: ID, whether it runs by default, whether it needs -/// `--include-tests`, and its question; groups and selection follow. +/// `--include-tests`, the levels that fail the default gate, how often its +/// reviews and considers were right on unseen projects, and its question; +/// what the columns mean, groups and selection follow. pub fn table() -> String { let rules = rules(); let width = rules.iter().map(|r| r.id.len()).max().unwrap_or(0); - let mut lines = vec![format!("{:width$} DEFAULT QUESTION", "RULE")]; - for rule in &rules { - let default = match (rule.default_enabled, rule.requires_tests) { - (false, _) => "opt-in", - (true, true) => "tests", - (true, false) => "yes", - }; - lines.push(format!( - "{:width$} {default:7} {}", - rule.id, rule.inspection - )); - } + let mut lines = vec![format!( + "{:width$} DEFAULT BLOCKS REVIEWS RIGHT CONSIDERS RIGHT QUESTION", + "RULE" + )]; + lines.extend(rules.iter().map(|rule| table_row(rule, width))); lines.push(String::new()); + lines.push(format!( + "BLOCKS: the levels that fail the check by default, right at least {}% of the time over at least {} labeled findings on projects JevGate was never tuned on; the rest are reported without failing it until they measure up. REVIEWS RIGHT and CONSIDERS RIGHT: the share of labeled findings right on those projects, a debatable one counting as not right.", + crate::maturity::MIN_PERCENT_RIGHT, + crate::maturity::MIN_LABELS + )); lines.push(format!( "Groups: {}, {DEFAULT_GROUP} (every rule marked yes or tests), {ALL_GROUP}.", groups().join(", ") )); - lines.push("Select with --rule and --skip-rule, or [rules] in jevgate.toml; `tests` rules need --include-tests.".into()); + lines.push("Select with --rule and --skip-rule, or [rules] in jevgate.toml; `tests` rules need --include-tests. --fail-on and [rules] levels replace the default gate.".into()); lines.join("\n") } +fn table_row(rule: &Rule, width: usize) -> String { + use crate::{maturity, schema::Strength}; + let default = match (rule.default_enabled, rule.requires_tests) { + (false, _) => "opt-in", + (true, true) => "tests", + (true, false) => "yes", + }; + let mature: Vec = maturity::mature_levels(rule.key) + .iter() + .map(crate::output::label) + .collect(); + let blocks = if mature.is_empty() { + "-".to_string() + } else { + mature.join(", ") + }; + let right = |level| { + maturity::measure(rule.key, level) + .and_then(|m| m.unseen.summary()) + .unwrap_or_else(|| "-".into()) + }; + format!( + "{:width$} {default:7} {blocks:8} {:13} {:15} {}", + rule.id, + right(Strength::Review), + right(Strength::Consider), + rule.inspection + ) +} + pub fn describe() -> Value { Value::Array( rules() .into_iter() .map(|r| { + let maturity = crate::maturity::describe(r.key); let mut value = serde_json::to_value(r).unwrap(); + value["maturity"] = maturity; value["decision_policy"] = serde_json::json!(policy()); value }) diff --git a/src/changes.rs b/src/changes.rs index 0ddf4e8..99ab475 100644 --- a/src/changes.rs +++ b/src/changes.rs @@ -158,6 +158,7 @@ mod tests { rank: 1.0, baselined: false, suppressed: None, + gate: None, } } diff --git a/src/config.rs b/src/config.rs index 2e64abc..2fce090 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,7 +33,7 @@ pub struct Config { pub max_file_bytes: Option, /// Ceiling on context bytes per request. Default: 32768. pub max_context_bytes: Option, - /// The level for rules without their own, like `--fail-on`. Default: ["review"]. + /// The level for rules without their own, like `--fail-on`. Default: ["mature"], which fails only on the levels of a rule measured right at least 80% of the time on projects JevGate was never tuned on; `jevgate rules` shows them. pub fail_on: Vec, /// TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it. pub model: Option, @@ -104,7 +104,7 @@ impl Level { .into_iter() .map(|name| { FailOn::parse(name).ok_or_else(|| { - anyhow!("Unknown level {name:?} for {target}; use review, consider, uncertain, report or off") + anyhow!("Unknown level {name:?} for {target}; use review, consider, mature, uncertain, report or off") }) }) .collect() @@ -202,7 +202,7 @@ impl ConfigContext { /// Each enabled rule's gate levels. The command line wins over the file; /// within each, a rule's own entry wins over its group's, then over the - /// levels for every rule, then `review`. + /// levels for every rule, then `mature`. fn configure_gate(&self, args: &mut CheckArgs) -> Result<()> { let cli = Levels::from_cli(&args.fail_on_specs)?; let file = self.file_levels()?; @@ -210,7 +210,7 @@ impl ConfigContext { .into_iter() .find(|levels| !levels.is_empty()) .cloned() - .unwrap_or_else(|| vec![FailOn::Review]); + .unwrap_or_else(|| vec![FailOn::Mature]); args.fail_on = fallback.clone(); args.rule_fail_on.clear(); for rule in catalog::rules() { @@ -444,7 +444,7 @@ mod tests { fn default_group_runs_when_nothing_is_configured() { let args = configured("", &[], &[]).unwrap(); assert_eq!(args.rules, catalog::select(catalog::DEFAULT_GROUP).unwrap()); - assert_eq!(args.fail_on, [FailOn::Review]); + assert_eq!(args.fail_on, [FailOn::Mature]); assert!(args.rule_fail_on.is_empty()); } @@ -463,7 +463,6 @@ mod tests { &[], ) .unwrap(); - assert!(!args.rules.iter().any(|r| r == catalog::HARDCODED_VALUES)); assert_eq!(args.fail_on, [FailOn::Consider]); assert_eq!(args.levels(catalog::SHARED_LOGIC), [FailOn::Review]); assert_eq!(args.levels(catalog::TEST_REDUNDANCY), [FailOn::None]); @@ -544,6 +543,49 @@ mod tests { } } + #[test] + fn mature_is_the_default_and_a_level_like_the_others() { + let args = configured("", &["default", "documentation"], &[]).unwrap(); + assert_eq!(args.levels(catalog::COMMENTS), [FailOn::Mature]); + let names = |pairs: &[(&str, &str)]| -> BTreeMap> { + pairs + .iter() + .map(|(rule, level)| (rule.to_string(), vec![level.to_string()])) + .collect() + }; + assert_eq!( + args.mature_level_names(), + names(&[ + ("maintainability/function-simplification", "review"), + ("documentation/agent-context", "consider") + ]) + ); + let file = r#" + fail_on = ["consider"] + [rules] + security = "mature" + [[scope]] + paths = ["scripts/**"] + fail_on = ["mature", "uncertain"] + "#; + let args = configured(file, &["default", "security"], &[]).unwrap(); + assert_eq!(args.levels(catalog::SHARED_LOGIC), [FailOn::Consider]); + assert_eq!(args.levels(catalog::INJECTION), [FailOn::Mature]); + assert_eq!( + args.levels_at(catalog::SHARED_LOGIC, Path::new("scripts/a.py")), + [FailOn::Mature, FailOn::Uncertain] + ); + assert_eq!( + args.mature_level_names(), + names(&[("maintainability/function-simplification", "review")]), + "mature in a scope; injection has no mature level" + ); + let flagged = configured(file, &["default"], &[(None, FailOn::Mature)]).unwrap(); + assert_eq!(flagged.levels(catalog::SHARED_LOGIC), [FailOn::Mature]); + let explicit = configured("fail_on = [\"review\"]", &[], &[]).unwrap(); + assert!(explicit.mature_level_names().is_empty()); + } + #[test] fn file_settings_apply_unless_a_flag_sets_them() { let file = "model = \"jev-latest\"\ncache_ttl_secs = 60\ninclude_tests = true\n"; diff --git a/src/config_schema.rs b/src/config_schema.rs index 59a504c..0d5ae85 100644 --- a/src/config_schema.rs +++ b/src/config_schema.rs @@ -7,7 +7,14 @@ use serde_json::{Value, json}; const ID: &str = "https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/main/jevgate.schema.json"; /// Levels `fail_on` accepts; `[rules]` also accepts `off`. -const LEVELS: [&str; 5] = ["review", "consider", "uncertain", "report", "none"]; +const LEVELS: [&str; 6] = [ + "review", + "consider", + "mature", + "uncertain", + "report", + "none", +]; pub fn schema() -> Value { let mut schema = serde_json::to_value(schemars::schema_for!(Config)).expect("a schema is JSON"); diff --git a/src/evaluate.rs b/src/evaluate.rs index 994b4dd..1c27e17 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -112,6 +112,7 @@ fn empty_report(args: &CheckArgs, current: &SnapshotContext<'_>, files: Vec u8 { } } +/// Record how the gate counts each finding, then decide it for a complete run. pub fn evaluate(report: &mut Report, args: &CheckArgs) { + for file in &mut report.files { + for finding in &mut file.findings { + finding.gate = gating(finding, &file.path, args); + } + } // Notes are optional improvements; no gate counts them. let findings = report .files .iter() - .flat_map(|f| f.findings.iter().map(|finding| (f.path.as_path(), finding))) - .filter(|(_, f)| f.strength != Strength::Note); - let baselined = findings.clone().filter(|(_, f)| f.baselined).count(); + .flat_map(|f| &f.findings) + .filter(|f| f.strength != Strength::Note); + let baselined = findings.clone().filter(|f| f.baselined).count(); let suppressed = findings .clone() - .filter(|(_, f)| !f.baselined && f.suppressed.is_some()) + .filter(|f| !f.baselined && f.suppressed.is_some()) .count(); - let new: Vec<_> = findings.filter(|(_, f)| !f.accepted()).collect(); + let new: Vec<_> = findings.filter(|f| !f.accepted()).collect(); let reasons = failures(report, &new, args); report.gate = report.complete.then_some(Gate { passed: reasons.is_empty(), @@ -52,28 +59,36 @@ pub fn evaluate(report: &mut Report, args: &CheckArgs) { }); } -/// Whether a finding in `path` fails the gate: new, not a note, and at its -/// rule's level for that path. Consider is the lower bar, so it also fails -/// on review findings. -pub fn fails(finding: &Finding, path: &Path, args: &CheckArgs) -> bool { +/// How the gate counts a finding in `path`, at its rule's levels for that +/// path: none for notes and accepted findings. Consider counts every finding +/// and review only reviews; `mature` counts the rule's mature levels, and a +/// finding it leaves out is still being measured. +fn gating(finding: &Finding, path: &Path, args: &CheckArgs) -> Option { + if finding.accepted() || finding.strength == Strength::Note { + return None; + } let levels = args.levels_at(&finding.rule, path); - !finding.accepted() - && finding.strength != Strength::Note - && (levels.contains(&FailOn::Consider) - || (finding.strength == Strength::Review && levels.contains(&FailOn::Review))) + let mature = levels.contains(&FailOn::Mature); + let counted = levels.contains(&FailOn::Consider) + || (finding.strength == Strength::Review && levels.contains(&FailOn::Review)) + || (mature && crate::maturity::mature(&finding.rule, finding.strength)); + Some(if counted { + Gating::Fails + } else if mature { + Gating::Measuring + } else { + Gating::Advisory + }) } /// Why the gate fails: new findings at their rule's level, or undecided /// results of a rule whose level includes `uncertain`. -fn failures(report: &Report, new: &[(&Path, &Finding)], args: &CheckArgs) -> Vec { +fn failures(report: &Report, new: &[&Finding], args: &CheckArgs) -> Vec { let mut reasons = Vec::new(); - let failing: Vec<_> = new - .iter() - .filter(|(path, f)| fails(f, path, args)) - .collect(); + let failing: Vec<_> = new.iter().filter(|f| f.fails_gate()).collect(); let review = failing .iter() - .filter(|(_, f)| f.strength == Strength::Review) + .filter(|f| f.strength == Strength::Review) .count(); let consider = failing.len() - review; if review > 0 { diff --git a/src/github.rs b/src/github.rs index f0fd098..2b0d239 100644 --- a/src/github.rs +++ b/src/github.rs @@ -12,8 +12,9 @@ use std::{io::Write, path::Path}; const SUMMARY_ROWS: usize = 50; /// Annotations for run errors, failed files and every new finding that is not -/// a note (an error when it fails the gate, else a warning), then the agent -/// text. The summary goes to `$GITHUB_STEP_SUMMARY` when the runner sets it. +/// a note (an error when it fails the gate, else a warning, which says so when +/// its rule and level are still being measured), then the agent text. The +/// summary goes to `$GITHUB_STEP_SUMMARY` when the runner sets it. pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<()> { for error in &report.errors { writeln!(out, "::error title=JevGate run incomplete::{}", data(error))?; @@ -27,23 +28,19 @@ pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<( data(error) )?; } - let shown: Vec<(&Path, &Finding)> = output::ranked(report) + let shown: Vec<(&Path, &Finding)> = output::failing_first(report) .into_iter() .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) .collect(); for (path, finding) in &shown { - writeln!( - out, - "{}", - annotation(path, finding, crate::gate::fails(finding, path, args)) - )?; + writeln!(out, "{}", annotation(path, finding))?; } if let Some(file) = std::env::var_os("GITHUB_STEP_SUMMARY") { let written = std::fs::OpenOptions::new() .append(true) .create(true) .open(&file) - .and_then(|mut f| f.write_all(summary(report, &shown, args).as_bytes())); + .and_then(|mut f| f.write_all(summary(report, &shown).as_bytes())); if let Err(error) = written { note!("jevgate: cannot write the job summary: {error}"); } @@ -51,19 +48,27 @@ pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<( output::agent(out, report, args.verbose, output::Style::PLAIN) } -fn annotation(path: &Path, finding: &Finding, fails: bool) -> String { +fn annotation(path: &Path, finding: &Finding) -> String { let end = finding .locations .iter() .find(|l| l.path == path && l.start_line == finding.line) .map_or(String::new(), |l| format!(",endLine={}", l.end_line)); + let mut message = format!("{}\n→ {}", finding.message, finding.action); + if let Some(note) = output::measuring_note(finding) { + message.push_str(&format!("\n{note}")); + } format!( "::{} file={},line={}{end},title={}::{}", - if fails { "error" } else { "warning" }, + if finding.fails_gate() { + "error" + } else { + "warning" + }, property(&path.to_string_lossy()), finding.line, property(&format!("JevGate {} [{}]", label(finding), finding.rule)), - data(&format!("{}\n→ {}", finding.message, finding.action)), + data(&message), ) } @@ -71,8 +76,9 @@ fn label(finding: &Finding) -> String { output::label(&finding.strength) } -/// The Markdown job summary: the headline, then a table of findings. -fn summary(report: &Report, shown: &[(&Path, &Finding)], args: &CheckArgs) -> String { +/// The Markdown job summary: the headline, then a table of findings, with +/// the ones that fail the gate in bold. +fn summary(report: &Report, shown: &[(&Path, &Finding)]) -> String { let mut text = format!("### {}\n\n", output::headline(report)); for error in &report.errors { text.push_str(&format!("- **Error:** {}\n", cell(error))); @@ -94,7 +100,7 @@ fn summary(report: &Report, shown: &[(&Path, &Finding)], args: &CheckArgs) -> St } text.push_str("| | Location | Rule | Finding |\n|---|---|---|---|\n"); for (path, finding) in shown.iter().take(SUMMARY_ROWS) { - let level = if crate::gate::fails(finding, path, args) { + let level = if finding.fails_gate() { format!("**{}**", label(finding)) } else { label(finding) @@ -114,6 +120,9 @@ fn summary(report: &Report, shown: &[(&Path, &Finding)], args: &CheckArgs) -> St shown.len() - SUMMARY_ROWS )); } + if let Some(line) = output::measuring(report) { + text.push_str(&format!("\n{line}\n")); + } text.push('\n'); text } @@ -138,25 +147,62 @@ fn cell(text: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::tests::finding; + use crate::{schema::Gating, tests::finding}; + + fn counted(strength: Strength, gate: Gating) -> Finding { + Finding { + gate: Some(gate), + ..finding(strength) + } + } #[test] fn annotations_escape_commands_and_mark_what_fails_the_gate() { - let line = annotation(Path::new("src/a,b.rs"), &finding(Strength::Review), true); + let review = counted(Strength::Review, Gating::Fails); + let line = annotation(Path::new("src/a,b.rs"), &review); assert_eq!( line, "::error file=src/a%2Cb.rs,line=12,endLine=20,title=JevGate review [maintainability/shared-logic]::Copies: 50%25 alike,%0Asee `b`%0A→ Share one | implementation" ); assert!(!line.contains('\n')); - let consider = annotation(Path::new("x.rs"), &finding(Strength::Consider), false); + let consider = annotation(Path::new("x.rs"), &finding(Strength::Consider)); assert!(consider.starts_with("::warning file=x.rs,line=12,title=")); } + #[test] + fn a_review_still_being_measured_is_a_warning_that_says_why() { + let review = counted(Strength::Review, Gating::Measuring); + let line = annotation(Path::new("x.rs"), &review); + assert!(line.starts_with("::warning file=x.rs,"), "{line}"); + assert!( + line.ends_with("%0ADoes not fail the gate: maintainability/shared-logic reviews are still being measured (54%25 of 85 right on projects JevGate was never tuned on)."), + "{line}" + ); + } + + #[test] + fn the_summary_says_why_reviews_still_being_measured_did_not_fail() { + let report = crate::tests::gated( + vec![crate::tests::finding_of( + "maintainability/shared-logic", + Strength::Review, + )], + &crate::tests::args(), + ); + let shown = output::failing_first(&report); + let text = summary(&report, &shown); + assert!(text.contains("| review | `src/lib.rs:12`"), "{text}"); + assert!( + text.contains("\n1 review did not fail the gate: by default only rules and levels"), + "{text}" + ); + } + #[test] fn summary_rows_stay_on_one_line_and_bold_gate_failures() { let args = crate::tests::args(); - let review = finding(Strength::Review); - let consider = finding(Strength::Consider); + let review = counted(Strength::Review, Gating::Fails); + let consider = counted(Strength::Consider, Gating::Measuring); let path = Path::new("src/a.rs"); let report = crate::evaluate::snapshot( &[], @@ -168,7 +214,7 @@ mod tests { requests: 0, }, ); - let text = summary(&report, &[(path, &review), (path, &consider)], &args); + let text = summary(&report, &[(path, &review), (path, &consider)]); let rows: Vec<&str> = text.lines().filter(|l| l.starts_with("| ")).collect(); assert_eq!(rows.len(), 3, "{text}"); assert!( diff --git a/src/gitlab.rs b/src/gitlab.rs index 560b6d4..44b2ae7 100644 --- a/src/gitlab.rs +++ b/src/gitlab.rs @@ -1,7 +1,6 @@ //! `--format gitlab`: the findings as a GitLab Code Quality report, which //! merge requests show as a widget and on the changed lines. use crate::{ - options::CheckArgs, output, schema::{Finding, Report, Strength}, }; @@ -10,29 +9,34 @@ use serde_json::{Value, json}; use std::{io::Write, path::Path}; /// The findings the GitHub annotations show: `major` when a finding fails the -/// gate, `minor` otherwise. -pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<()> { +/// gate, `minor` otherwise, and the description says when its rule and level +/// are still being measured. +pub fn emit(out: &mut impl Write, report: &Report) -> Result<()> { let issues: Vec = output::ranked(report) .into_iter() .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) - .map(|(path, finding)| issue(path, finding, crate::gate::fails(finding, path, args))) + .map(|(path, finding)| issue(path, finding)) .collect(); serde_json::to_writer_pretty(&mut *out, &issues)?; writeln!(out)?; Ok(()) } -fn issue(path: &Path, finding: &Finding, fails: bool) -> Value { +fn issue(path: &Path, finding: &Finding) -> Value { let end = finding .locations .iter() .find(|l| l.path == path && l.start_line == finding.line) .map_or(finding.line, |l| l.end_line.max(finding.line)); + let mut description = format!("{} Next step: {}", finding.message, finding.action); + if let Some(note) = output::measuring_note(finding) { + description.push_str(&format!(" {note}")); + } json!({ - "description": format!("{} Next step: {}", finding.message, finding.action), + "description": description, "check_name": finding.rule, "fingerprint": fingerprint(path, finding), - "severity": if fails { "major" } else { "minor" }, + "severity": if finding.fails_gate() { "major" } else { "minor" }, "location": { "path": path.to_string_lossy(), "lines": {"begin": finding.line.max(1), "end": end.max(1)}, @@ -58,12 +62,16 @@ fn fingerprint(path: &Path, finding: &Finding) -> String { #[cfg(test)] mod tests { use super::*; - use crate::tests::finding; + use crate::{schema::Gating, tests::finding}; #[test] fn issues_carry_severity_location_and_a_fingerprint() { let path = Path::new("src/a,b.rs"); - let review = issue(path, &finding(Strength::Review), true); + let failing = Finding { + gate: Some(Gating::Fails), + ..finding(Strength::Review) + }; + let review = issue(path, &failing); assert_eq!(review["severity"], "major"); assert_eq!(review["check_name"], "maintainability/shared-logic"); assert_eq!(review["location"]["path"], "src/a,b.rs"); @@ -75,10 +83,22 @@ mod tests { .ends_with("Next step: Share one | implementation") ); assert_eq!(review["fingerprint"].as_str().unwrap().len(), 64); - let consider = issue(path, &finding(Strength::Consider), false); + let consider = issue(path, &finding(Strength::Consider)); assert_eq!(consider["severity"], "minor"); let mut named = finding(Strength::Consider); named.fingerprint = "abc".into(); - assert_eq!(issue(path, &named, false)["fingerprint"], "abc"); + assert_eq!(issue(path, &named)["fingerprint"], "abc"); + let measuring = Finding { + gate: Some(Gating::Measuring), + ..finding(Strength::Review) + }; + let measured = issue(path, &measuring); + assert_eq!(measured["severity"], "minor"); + assert!( + measured["description"] + .as_str() + .unwrap() + .ends_with("Next step: Share one | implementation Does not fail the gate: maintainability/shared-logic reviews are still being measured (54% of 85 right on projects JevGate was never tuned on).") + ); } } diff --git a/src/html_report.rs b/src/html_report.rs index fb5d4f9..540ccd7 100644 --- a/src/html_report.rs +++ b/src/html_report.rs @@ -43,7 +43,10 @@ pub fn render(report: &Report) -> Result { .collect(); let rules: Vec<_> = crate::catalog::rules() .into_iter() - .map(|r| json!({"key":r.key,"id":r.id,"description":r.inspection})) + .map(|r| { + json!({"key":r.key,"id":r.id,"description":r.inspection, + "maturity":crate::maturity::describe(r.key)}) + }) .collect(); // A run that leaves out default rules lists fewer files; say so. let partial = crate::catalog::rules() @@ -54,6 +57,7 @@ pub fn render(report: &Report) -> Result { "refresh":report.watcher_pid.is_some(),"model":report.requested_model, "requests":report.api_requests,"tokens":report.paid_input_tokens, "cost":batch_cost(report),"gate":report.gate,"fail_on":report.fail_on, + "fail_on_mature":report.fail_on_mature, "errors":report.errors,"deleted":report.deleted_files,"files":files,"rules":rules, "selected":report.rules,"partial":partial}); // Even a filename or analyzer message may contain . Never let data @@ -148,7 +152,15 @@ mod tests { .unwrap() .is_empty() ); - assert_eq!(decoded["fail_on"], serde_json::json!(["review"])); + assert_eq!(decoded["fail_on"], serde_json::json!(["mature"])); + assert_eq!( + decoded["fail_on_mature"], + serde_json::json!({"maintainability/function-simplification": ["review"], "documentation/agent-context": ["consider"]}) + ); + assert_eq!( + decoded["rules"][1]["maturity"]["review"]["unseen"], + serde_json::json!({"right": 20, "labeled": 23}) + ); assert!(!html.contains("id=\"root\"")); report.paid_input_tokens = 1_000_000; report.paid_output_tokens = 12_345; diff --git a/src/init.rs b/src/init.rs index 35e5c7b..5f42b5e 100644 --- a/src/init.rs +++ b/src/init.rs @@ -90,23 +90,7 @@ fn render(allow: &[String]) -> String { } else { format!("upload_allow = {}\n", list(allow)) }; - let mut rules = String::new(); - for group in catalog::groups() { - let members: Vec<_> = catalog::rules() - .into_iter() - .filter(|r| r.group == group) - .collect(); - let names: Vec<&str> = members - .iter() - .map(|r| r.id.trim_start_matches(&format!("{group}/")[..])) - .collect(); - let comment = format!("# {}", names.join(", ")); - if members.iter().all(|r| r.default_enabled) { - rules.push_str(&format!("{group} = \"review\" {comment}\n")); - } else { - rules.push_str(&format!("# {group} = \"consider\" {comment} (opt-in)\n")); - } - } + let rules: String = catalog::groups().into_iter().map(group_example).collect(); format!( r#"#:schema https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/v{version}/jevgate.schema.json # JevGate configuration, written by `jevgate init`. Unknown keys are errors. @@ -129,10 +113,14 @@ upload_deny = ["**/.env*", "**/*.pem", "**/*.key"] # max_requests = 200 # concurrency = 4 -# Each group or rule ID set to a level is judged and fails the check at that -# level: "review", "consider" (also fails on review), "uncertain", "report" -# (judge, never fail) or "off". A rule's own entry wins over its group's. -# Test rules also need include_tests or --include-tests. +# Unset, the default rules run and only rule levels measured right at least +# 80% of the time on projects JevGate was never tuned on fail the check +# ("mature"; `jevgate rules` shows them); other findings are reported without +# failing it. A group or rule ID set to a level is judged, every rule of a +# group included, and fails the check at exactly that level: "review", +# "consider" (also fails on review), "mature", "uncertain", "report" (judge, +# never fail) or "off". A rule's own entry wins over its group's. Test rules +# also need include_tests or --include-tests. [rules] {rules} # Levels for the files some paths match, such as report-only tooling. The last @@ -146,6 +134,33 @@ upload_deny = ["**/.env*", "**/*.pem", "**/*.key"] ) } +/// A commented `[rules]` line for one group: a level to set, and its rules, +/// with the ones that do not run by default marked opt-in. +fn group_example(group: &str) -> String { + let members: Vec<_> = catalog::rules() + .into_iter() + .filter(|r| r.group == group) + .collect(); + let opt_in = members.iter().all(|r| !r.default_enabled); + let names: Vec = members + .iter() + .map(|r| { + let name = r.id.trim_start_matches(&format!("{group}/")[..]); + if r.default_enabled || opt_in { + name.to_string() + } else { + format!("{name} (opt-in)") + } + }) + .collect(); + let (level, suffix) = if opt_in { + ("consider", " (opt-in)") + } else { + ("review", "") + }; + format!("# {group} = \"{level}\" # {}{suffix}\n", names.join(", ")) +} + #[cfg(test)] mod tests { use super::*; @@ -181,12 +196,25 @@ mod tests { ".cursor/rules/**" ] ); - let config: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let config: Config = toml::from_str(&text).unwrap(); assert_eq!(config.upload_allow, allow); - let Rules::Levels(levels) = config.rules else { - panic!("rules is a table of levels"); + let levels = |config: Config| match config.rules { + Rules::Levels(levels) => levels, + Rules::List(_) => panic!("rules is a table of levels"), }; - assert!(levels.contains_key("maintainability") && levels.contains_key("tests")); + assert!(levels(config).is_empty(), "the default rules and gate"); + let uncommented: Vec<&str> = text + .lines() + .map(|line| { + let example = catalog::groups() + .into_iter() + .any(|g| line.starts_with(&format!("# {g} = "))); + if example { &line[2..] } else { line } + }) + .collect(); + let config: Config = toml::from_str(&uncommented.join("\n")).unwrap(); + assert_eq!(levels(config).len(), catalog::groups().len()); assert!(run(&dir, false).is_err(), "an existing file is kept"); assert!(run(&dir, true).is_ok()); } diff --git a/src/main.rs b/src/main.rs index 4a71eb5..f24758c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ mod inventory; mod line_ranges; mod locations; mod manual; +mod maturity; mod mcp; mod options; mod output; @@ -77,7 +78,9 @@ use options::JevCommand; /// reported as uncertain instead of hidden. /// /// Rule groups: maintainability (on by default), tests (with -/// --include-tests), and the opt-in security and documentation groups. +/// --include-tests), and the opt-in security and documentation groups. By +/// default only rules and levels measured right at least 80% of the time on +/// projects JevGate was never tuned on fail the check. #[derive(Parser)] #[command(version, after_long_help = options::OVERVIEW)] pub struct Cli { diff --git a/src/maturity.rs b/src/maturity.rs new file mode 100644 index 0000000..f63dbc2 --- /dev/null +++ b/src/maturity.rs @@ -0,0 +1,237 @@ +//! Which rules and levels fail the gate by default. A rule and level is +//! mature when its findings were right at least 80% of the time on projects +//! JevGate was never tuned on, over at least 20 findings labeled by hand from +//! the code. The default gate level, `mature`, fails only on those; every +//! other finding is reported without failing the check, until its rule and +//! level measure up. The concern probability could not decide this: on those +//! projects, reviews were right 55%, 46%, 56% and 61% of the time with a +//! probability below 0.90, below 0.95, below 0.98 and above. +use crate::{ + catalog, + schema::Strength::{self, Consider, Review}, +}; +use serde_json::{Value, json}; + +/// Labeled findings a rule and level needs on unseen projects to be mature. +pub const MIN_LABELS: u32 = 20; +/// The share of those findings, in percent, that must be right. +pub const MIN_PERCENT_RIGHT: u32 = 80; + +/// Findings of one rule and level labeled by hand: how many were right, of +/// how many labeled. A debatable label counts as not right. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Labels { + pub right: u32, + pub labeled: u32, +} + +impl Labels { + /// The share right in whole percent, half rounded up as the HTML report + /// and the site round it; none without labels. + pub fn percent(self) -> Option { + (self.labeled > 0).then(|| (200 * self.right + self.labeled) / (2 * self.labeled)) + } + + /// "87% of 23"; none without labels. + pub fn summary(self) -> Option { + self.percent().map(|p| format!("{p}% of {}", self.labeled)) + } + + fn describe(self) -> Value { + json!({"right": self.right, "labeled": self.labeled}) + } +} + +/// One rule and level's labels on the projects never used for tuning and on +/// the ones the rules were tuned on. +pub struct Measure { + /// The rule's catalog key. + pub rule: &'static str, + pub level: Strength, + pub unseen: Labels, + pub tuned: Labels, +} + +impl Measure { + /// Right at least [`MIN_PERCENT_RIGHT`] of the time over at least + /// [`MIN_LABELS`] labels on unseen projects. + pub fn mature(&self) -> bool { + let Labels { right, labeled } = self.unseen; + labeled >= MIN_LABELS && 100 * right >= MIN_PERCENT_RIGHT * labeled + } +} + +const fn row(rule: &'static str, level: Strength, unseen: [u32; 2], tuned: [u32; 2]) -> Measure { + Measure { + rule, + level, + unseen: Labels { + right: unseen[0], + labeled: unseen[1], + }, + tuned: Labels { + right: tuned[0], + labeled: tuned[1], + }, + } +} + +/// Measured on 2026-09-28 from the corpus's hand labels +/// (`evaluation/labels`, joined by fingerprint) and JevGate 0.25.0's findings, +/// replayed from the answer cache over the 94 labeled projects outside Bend 2; +/// the few files whose 0.25.0 requests the cache lacked keep their 0.24.1 +/// findings. Unseen: [right, labeled] on the 11 held-out and 14 fresh projects +/// never used for tuning (22 of them have findings); tuned: on the other 72. +/// `docs/research/2026-09-28/scripts/maturity.py` in the maintainer's clone +/// prints these rows. Two are mature: function-simplification reviews (20 of +/// 23) and agent-context considers (22 of 24). The gap between the columns is +/// why only unseen projects count: shared-logic reviews were right 75% of the +/// time on tuned projects and 54% on unseen ones. +const TABLE: [Measure; 26] = [ + row(catalog::FILE_ORGANIZATION, Review, [2, 5], [15, 30]), + row(catalog::FILE_ORGANIZATION, Consider, [17, 29], [19, 43]), + row(catalog::FUNCTION_SIMPLIFICATION, Review, [20, 23], [57, 69]), + row( + catalog::FUNCTION_SIMPLIFICATION, + Consider, + [85, 126], + [147, 197], + ), + row(catalog::SHARED_LOGIC, Review, [46, 85], [181, 240]), + row(catalog::SHARED_LOGIC, Consider, [85, 158], [146, 244]), + row(catalog::HARDCODED_VALUES, Review, [1, 8], [15, 28]), + row(catalog::HARDCODED_VALUES, Consider, [5, 29], [32, 57]), + row(catalog::INJECTION, Review, [3, 4], [81, 96]), + row(catalog::INJECTION, Consider, [5, 13], [27, 47]), + row(catalog::SENSITIVE_DATA, Review, [10, 24], [41, 64]), + row(catalog::SENSITIVE_DATA, Consider, [0, 5], [12, 15]), + row(catalog::UNSAFE_SETTINGS, Review, [2, 4], [53, 72]), + row(catalog::UNSAFE_SETTINGS, Consider, [0, 4], [16, 21]), + row(catalog::ACCESS_CONTROL, Review, [0, 0], [2, 5]), + row(catalog::ACCESS_CONTROL, Consider, [0, 0], [5, 14]), + row(catalog::WORKFLOWS, Review, [1, 1], [0, 1]), + row(catalog::TEST_VALUE, Review, [3, 5], [5, 11]), + row(catalog::TEST_VALUE, Consider, [1, 2], [20, 27]), + row(catalog::TEST_REDUNDANCY, Review, [1, 1], [4, 4]), + row(catalog::TEST_REDUNDANCY, Consider, [27, 45], [27, 34]), + row(catalog::AGENT_CONTEXT, Consider, [22, 24], [64, 68]), + row(catalog::LARGE_DOCS, Consider, [1, 1], [1, 5]), + row(catalog::DOC_STALENESS, Consider, [2, 2], [14, 15]), + row(catalog::DOC_DUPLICATION, Consider, [3, 20], [5, 22]), + row(catalog::COMMENTS, Consider, [39, 72], [97, 150]), +]; + +/// The labels of a rule, by ID, name or key, at one level. +pub fn measure(rule: &str, level: Strength) -> Option<&'static Measure> { + let key = catalog::find(rule)?.key; + TABLE.iter().find(|m| m.rule == key && m.level == level) +} + +/// Whether the default gate fails on findings of `rule` at `level`. +pub fn mature(rule: &str, level: Strength) -> bool { + measure(rule, level).is_some_and(Measure::mature) +} + +/// A rule's mature levels, review first. +pub fn mature_levels(rule: &str) -> Vec { + [Review, Consider] + .into_iter() + .filter(|level| mature(rule, *level)) + .collect() +} + +/// A rule's measured levels for `jevgate rules --format json`: labels on +/// unseen and tuned projects, and whether each level is mature. +pub fn describe(rule: &str) -> Value { + let levels = [Review, Consider].into_iter().filter_map(|level| { + measure(rule, level).map(|m| { + let value = json!({ + "unseen": m.unseen.describe(), + "tuned": m.tuned.describe(), + "mature": m.mature(), + }); + (crate::output::label(&level), value) + }) + }); + Value::Object(levels.collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unseen(right: u32, labeled: u32) -> Measure { + row(catalog::SHARED_LOGIC, Review, [right, labeled], [0, 0]) + } + + #[test] + fn a_level_is_mature_at_eighty_percent_over_twenty_labels() { + assert!(unseen(16, 20).mature(), "exactly 80% of 20"); + assert!(!unseen(15, 20).mature()); + assert!(!unseen(19, 19).mature(), "too few labels"); + assert!(unseen(20, 23).mature()); + assert!(!unseen(0, 0).mature()); + } + + #[test] + fn the_table_names_catalog_rules_once_per_level() { + let keys = catalog::keys(); + for (i, m) in TABLE.iter().enumerate() { + assert!(keys.contains(&m.rule), "{}", m.rule); + assert!(m.level != Strength::Note, "{}", m.rule); + for labels in [m.unseen, m.tuned] { + assert!(labels.right <= labels.labeled, "{}", m.rule); + } + assert!( + !TABLE[..i] + .iter() + .any(|other| other.rule == m.rule && other.level == m.level), + "{} twice", + m.rule + ); + } + } + + #[test] + fn function_simplification_reviews_and_agent_context_considers_are_mature() { + let mature: Vec<(&str, Strength)> = TABLE + .iter() + .filter(|m| m.mature()) + .map(|m| (m.rule, m.level)) + .collect(); + assert_eq!( + mature, + [ + (catalog::FUNCTION_SIMPLIFICATION, Review), + (catalog::AGENT_CONTEXT, Consider) + ] + ); + assert!(self::mature( + "maintainability/function-simplification", + Review + )); + assert!(!self::mature("function-simplification", Consider)); + assert!(!self::mature(catalog::LAWS, Review), "never measured"); + assert_eq!(mature_levels(catalog::AGENT_CONTEXT), [Consider]); + assert!(mature_levels("nothing").is_empty()); + } + + #[test] + fn measured_levels_describe_their_labels() { + let value = describe(catalog::FUNCTION_SIMPLIFICATION); + assert_eq!( + value["review"], + json!({"unseen": {"right": 20, "labeled": 23}, "tuned": {"right": 57, "labeled": 69}, "mature": true}) + ); + assert_eq!(value["consider"]["mature"], false); + assert_eq!(describe(catalog::LAWS), json!({})); + let unseen = |rule| measure(rule, Review).unwrap().unseen.summary(); + assert_eq!(unseen(catalog::SHARED_LOGIC).as_deref(), Some("54% of 85")); + assert_eq!( + unseen(catalog::HARDCODED_VALUES).as_deref(), + Some("13% of 8"), + "12.5% rounds up" + ); + assert_eq!(unseen(catalog::ACCESS_CONTROL), None); + } +} diff --git a/src/mcp.rs b/src/mcp.rs index b2b9814..64abcbf 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -18,7 +18,9 @@ const MAX_FINDINGS: usize = 50; const INSTRUCTIONS: &str = "JevGate reviews code by asking TypeSafe Jev small questions about functions, files, tests and docs. \ Call jevgate_check with `base` (such as origin/main) to review what changed; it uses the repository's jevgate.toml and TYPESAFE_API_KEY, and paid requests only for code the answer cache lacks. \ -Fix each `review` finding; for a `consider`, fix it or explain why the code should stay. Exit code 2 means the run could not finish: report it, never treat it as a pass. \ +Fix each `review` finding; for a `consider`, fix it or explain why the code should stay. \ +Findings marked to fail the gate (`gate: fails` in jevgate_findings) decide the exit code; by default only rules and levels measured right at least 80% of the time do, and the rest are reported. \ +Exit code 2 means the run could not finish: report it, never treat it as a pass. \ jevgate_findings reads the last report without running anything."; pub fn run() -> Result<()> { @@ -111,7 +113,8 @@ impl Server { Ok(format!("{text}\n\n({meaning})")) } - /// Findings of the last report, ranked, optionally for one path prefix. + /// Findings of the last report, those that fail the gate first, then by + /// rank, optionally for one path prefix. fn findings(&self, arguments: &Value) -> Result { let report = crate::storage::read_latest(&self.root) .context("No report yet; call jevgate_check first")?; @@ -170,8 +173,10 @@ fn strings(value: &Value, name: &str) -> Result> { } } +/// The findings of `report`, those that fail the gate first, so the cap +/// never leaves one out for a finding that only warns. fn findings(report: &Report, prefix: Option<&str>, include_notes: bool) -> Value { - let all: Vec = output::ranked(report) + let all: Vec = output::failing_first(report) .into_iter() .filter(|(path, _)| prefix.is_none_or(|p| path.starts_with(p))) .filter(|(_, f)| include_notes || f.strength != crate::schema::Strength::Note) @@ -186,6 +191,7 @@ fn findings(report: &Report, prefix: Option<&str>, include_notes: bool) -> Value "probability": f.concern_probability, "baselined": f.baselined, "suppressed": f.suppressed, + "gate": f.gate, }) }) .collect(); @@ -339,6 +345,35 @@ mod tests { assert!(check_arguments(&json!({"paths": "src"})).is_err()); } + #[test] + fn findings_say_how_the_gate_counted_them() { + use crate::{schema::Strength, tests::finding_of}; + let lower = crate::schema::Finding { + rank: 0.5, + ..finding_of("maintainability/function-simplification", Strength::Review) + }; + let report = crate::tests::gated( + vec![ + finding_of("maintainability/shared-logic", Strength::Review), + lower, + ], + &crate::tests::args(), + ); + let value = findings(&report, None, false); + let gates: Vec<&Value> = value["findings"] + .as_array() + .unwrap() + .iter() + .map(|f| &f["gate"]) + .collect(); + assert_eq!( + gates, + [&json!("fails"), &json!("measuring")], + "a failure first, whatever its rank" + ); + assert_eq!(value["gate"]["passed"], false); + } + #[test] fn a_failed_tool_call_is_a_tool_error_not_a_protocol_error() { let reply = server() diff --git a/src/options/commands.rs b/src/options/commands.rs index 586fd7e..9ac27d1 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -66,14 +66,20 @@ pub enum JevCommand { #[command(subcommand)] action: Option, }, - /// List every rule with its group, default and the question it asks + /// List every rule with its default, the levels that fail the check by default, and its question /// /// A rule is named by its ID (`maintainability/shared-logic`), its key /// (`shared_logic`) or its group (`maintainability`, `tests`, `security`, /// `documentation`, plus `default` and `all`) anywhere a rule is accepted: /// `--rule`, `--skip-rule`, `--fail-on TARGET=LEVEL` and `[rules]`. + /// + /// Each rule shows how often its reviews and considers were right on + /// projects JevGate was never tuned on, from findings labeled by hand. The + /// levels right at least 80% of the time over at least 20 labels are + /// mature: by default only they fail the check (`--fail-on mature`), and + /// the other findings are reported without failing it. Rules { - /// `table` for people; `json` adds scope, evidence unit, version and decision policy + /// `table` for people; `json` adds scope, evidence unit, version, labels per level and decision policy #[arg(long, value_enum, default_value_t = RulesFormat::Table)] format: RulesFormat, }, @@ -224,6 +230,7 @@ Examples: jevgate check --rule comments Only code comments: repeated code, filler, narrated edits jevgate check --include-tests Also judge test value and redundancy jevgate check --fail-on none Advisory: never exits 1; exits 2 when incomplete + jevgate check --fail-on review Fail on every review, not only on mature rules jevgate check --fail-on review --fail-on security=consider jevgate check --dry-run --show-requests Exactly what would be uploaded, offline jevgate check --cache-only Replay cached answers; never contact TypeSafe @@ -231,10 +238,13 @@ Examples: Reading the JSON report (--format json or .jevgate/latest.json): complete false when any selected file was not judged; the exit code is then 2 gate passed, reasons, new_findings, baselined_findings + fail_on the gate levels; fail_on_mature says what `mature` stands for files[].status clear, note, consider, review, uncertain, needs-context, not-applicable, skipped or error files[].findings rule, strength, line, message, action, locations, - concern_probability, fingerprint, baselined + concern_probability, fingerprint, baselined, and gate: + fails, measuring (its rule and level are still being + measured) or advisory (below the level in force) files[].dimensions per rule: status, unit counts and the units left undecided files[].judgments every raw answer, first pass and follow-ups api_requests, paid_input_tokens, paid_output_tokens this run's usage"; diff --git a/src/options/mod.rs b/src/options/mod.rs index 447ba96..06a23f2 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -39,6 +39,9 @@ pub enum FailOn { Review, /// New review or consider findings Consider, + /// New findings of the rule's mature levels, measured right at least 80% + /// of the time on projects JevGate was never tuned on (the default) + Mature, /// Files whose answers stayed undecided or that need context Uncertain, /// Nothing; findings are advisory and only an incomplete run exits 2 @@ -50,6 +53,7 @@ impl FailOn { match self { Self::Review => "review", Self::Consider => "consider", + Self::Mature => "mature", Self::Uncertain => "uncertain", Self::None => "none", } @@ -78,7 +82,7 @@ fn fail_on_spec(value: &str) -> Result { None => (None, value.trim()), }; let level = FailOn::parse(level).ok_or_else(|| { - format!("Unknown level {level:?}; use review, consider, uncertain or none") + format!("Unknown level {level:?}; use review, consider, mature, uncertain or none") })?; Ok(FailOnSpec { target, level }) } @@ -147,14 +151,17 @@ pub struct CheckArgs { /// Deselect a rule ID, name, key or group (repeatable); applied after --rule and jevgate.toml #[arg(long = "skip-rule", value_name = "RULE", help_heading = RULES)] pub skip_rules: Vec, - /// What fails the gate: LEVEL for every rule, or TARGET=LEVEL (repeatable) [default: review] + /// What fails the gate: LEVEL for every rule, or TARGET=LEVEL (repeatable) [default: mature] /// - /// LEVEL is review, consider (also fails on review), uncertain, or none - /// (advisory; `report` is accepted as a synonym). TARGET is a rule ID, key - /// or group, for example `security=consider`; the most specific target - /// wins. Flags replace `fail_on` and `[rules]` levels from jevgate.toml - /// for the rules they address. Notes and baselined findings never fail the - /// gate. An incomplete run exits 2 regardless of the gate. + /// LEVEL is review, consider (also fails on review), mature, uncertain, or + /// none (advisory; `report` is accepted as a synonym). `mature` fails only + /// on the levels of a rule measured right at least 80% of the time on + /// projects JevGate was never tuned on (`jevgate rules` shows them); other + /// findings are reported without failing. TARGET is a rule ID, key or + /// group, for example `security=consider`; the most specific target wins. + /// Flags replace `fail_on` and `[rules]` levels from jevgate.toml for the + /// rules they address. Notes and baselined findings never fail the gate. + /// An incomplete run exits 2 regardless of the gate. #[arg(long = "fail-on", value_name = "[TARGET=]LEVEL", value_parser = fail_on_spec, help_heading = RULES)] pub fail_on_specs: Vec, /// The resolved levels for rules without their own: from --fail-on, else configuration. @@ -359,6 +366,30 @@ impl CheckArgs { .collect() } + /// What `mature` stands for, for the report: the mature levels of each + /// selected rule that has some and whose levels include `mature` outside + /// scopes or in one, by rule ID. + pub fn mature_level_names(&self) -> BTreeMap> { + let uses_mature = |key: &str| { + self.levels(key).contains(&FailOn::Mature) + || self.path_fail_on.iter().any(|scope| { + scope + .rules + .get(key) + .is_some_and(|l| l.contains(&FailOn::Mature)) + }) + }; + self.rules + .iter() + .filter(|key| uses_mature(key)) + .filter_map(|key| { + let levels = crate::maturity::mature_levels(key); + let names = levels.iter().map(crate::output::label).collect::>(); + (!names.is_empty()).then(|| (crate::catalog::id(key).to_string(), names)) + }) + .collect() + } + /// The model to ask: `--model`, else configuration, else [`DEFAULT_MODEL`]. pub fn model(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL) diff --git a/src/output.rs b/src/output.rs index 18055c9..a16bb9c 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,6 @@ use crate::{ options::{CheckArgs, ColorChoice, Format}, - schema::{FileResult, Finding, Report, Status, Strength}, + schema::{FileResult, Finding, Gating, Report, Status, Strength}, }; use anyhow::Result; use std::{ @@ -101,8 +101,8 @@ pub fn emit(report: &Report, args: &CheckArgs) -> Result<()> { Style::for_stdout(args.color), ), Format::Github => crate::github::emit(&mut out, report, args), - Format::Sarif => crate::sarif::emit(&mut out, report, args), - Format::Gitlab => crate::gitlab::emit(&mut out, report, args), + Format::Sarif => crate::sarif::emit(&mut out, report), + Format::Gitlab => crate::gitlab::emit(&mut out, report), }; match written { Err(error) if broken_pipe(&error) => Ok(()), @@ -137,6 +137,9 @@ pub(super) fn agent( ) -> Result<()> { emit_header(out, report, style)?; emit_findings(out, report, verbose, style)?; + if let Some(line) = measuring(report) { + writeln!(out, "\n{line}")?; + } emit_summary(out, report)?; if let Some(load) = &report.context_load { emit_context_load(out, load)?; @@ -209,10 +212,21 @@ pub(crate) fn ranked(report: &Report) -> Vec<(&Path, &Finding)> { findings } -/// Every review, then the top-ranked considers (all with `verbose`). Notes -/// are listed only with `verbose`; otherwise just counted. +/// Every finding with its file's path: those that fail the gate first, then +/// the rest, each highest rank first, so a capped list never leaves out a +/// failure for a higher-ranked finding that only warns. +pub(crate) fn failing_first(report: &Report) -> Vec<(&Path, &Finding)> { + let (failing, rest): (Vec<_>, Vec<_>) = ranked(report) + .into_iter() + .partition(|(_, f)| f.fails_gate()); + failing.into_iter().chain(rest).collect() +} + +/// Every review, then the top considers (all with `verbose`), those that +/// fail the gate first. Notes are listed only with `verbose`; otherwise just +/// counted. fn emit_findings(out: &mut impl Write, report: &Report, verbose: bool, style: Style) -> Result<()> { - let findings = ranked(report); + let findings = failing_first(report); let of = |strength: Strength| -> Vec<(&Path, &Finding)> { findings .iter() @@ -230,24 +244,7 @@ fn emit_findings(out: &mut impl Write, report: &Report, verbose: bool, style: St emit_section(out, &heading, BOLD_RED, &review, style)?; } if !consider.is_empty() { - let shown = if verbose { - consider.len() - } else { - TOP_CONSIDER - }; - let more = if consider.len() > shown { - format!(", top {shown}; --verbose shows all") - } else { - String::new() - }; - let heading = format!("Consider ({}{more}):", consider.len()); - emit_section( - out, - &heading, - BOLD_YELLOW, - &consider[..shown.min(consider.len())], - style, - )?; + emit_considers(out, &consider, verbose, style)?; } if notes.is_empty() { return Ok(()); @@ -264,6 +261,33 @@ fn emit_findings(out: &mut impl Write, report: &Report, verbose: bool, style: St emit_section(out, &heading, BOLD, ¬es, style) } +/// The top considers (all with `verbose`), under a heading that says how +/// many there are and which are shown. +fn emit_considers( + out: &mut impl Write, + consider: &[(&Path, &Finding)], + verbose: bool, + style: Style, +) -> Result<()> { + let shown = if verbose { + consider.len() + } else { + TOP_CONSIDER.min(consider.len()) + }; + let more = if consider.len() > shown { + let order = if consider.iter().any(|(_, f)| f.fails_gate()) { + ", those that fail the gate first" + } else { + "" + }; + format!(", top {shown}{order}; --verbose shows all") + } else { + String::new() + }; + let heading = format!("Consider ({}{more}):", consider.len()); + emit_section(out, &heading, BOLD_YELLOW, &consider[..shown], style) +} + /// A blank line, a heading, then its findings. fn emit_section( out: &mut impl Write, @@ -279,6 +303,50 @@ fn emit_section( Ok(()) } +/// Why reviews did not fail the gate when their rules and levels are still +/// being measured, with the considers beside them and how to make every +/// review fail it; none when no review is left out that way. +pub(crate) fn measuring(report: &Report) -> Option { + let left_out = |strength: Strength| { + report + .files + .iter() + .flat_map(|f| &f.findings) + .filter(|f| f.strength == strength && f.gate == Some(Gating::Measuring)) + .count() + }; + let (reviews, considers) = (left_out(Strength::Review), left_out(Strength::Consider)); + if reviews == 0 { + return None; + } + let considers = if considers > 0 { + format!(" and {}", count(considers, "consider")) + } else { + String::new() + }; + Some(format!( + "{}{considers} did not fail the gate: by default only rules and levels right at least {}% of the time on projects JevGate was never tuned on fail it, and theirs are still being measured. `jevgate rules` shows each one's precision; `--fail-on review` makes every review fail the gate.", + count(reviews, "review"), + crate::maturity::MIN_PERCENT_RIGHT + )) +} + +/// Why a finding still being measured does not fail the gate, with its rule +/// and level's precision on unseen projects; none for any other finding. +pub(crate) fn measuring_note(finding: &Finding) -> Option { + if finding.gate != Some(Gating::Measuring) { + return None; + } + let measured = crate::maturity::measure(&finding.rule, finding.strength) + .and_then(|m| m.unseen.summary()) + .map_or_else(|| "none labeled yet".into(), |s| format!("{s} right")); + Some(format!( + "Does not fail the gate: {} {}s are still being measured ({measured} on projects JevGate was never tuned on).", + finding.rule, + label(&finding.strength) + )) +} + /// Counts of undecided, unsent and failed files, and skip reasons. fn emit_summary(out: &mut impl Write, report: &Report) -> Result<()> { let count = |status: Status| report.files.iter().filter(|f| f.status == status).count(); @@ -353,8 +421,8 @@ fn emit_context_load(out: &mut impl Write, load: &crate::docs::load::ContextLoad Ok(()) } -/// `path:line [rule] message`, then the next step; the location is bold and -/// the rule dim. +/// `path:line [rule] message`, then the next step; the location is bold, +/// the rule dim, and a finding that fails the gate says so in red. fn emit_finding(out: &mut impl Write, path: &Path, finding: &Finding, style: Style) -> Result<()> { let location = format!("{}:{}", path.display(), finding.line); let accepted = match (&finding.suppressed, finding.baselined) { @@ -363,9 +431,14 @@ fn emit_finding(out: &mut impl Write, path: &Path, finding: &Finding, style: Sty (None, false) => String::new(), }; let rule = format!("[{}]{accepted}", finding.rule); + let fails = if finding.fails_gate() { + format!("{} ", style.paint(RED, "(fails the gate)")) + } else { + String::new() + }; writeln!( out, - " {} {} {}", + " {} {} {fails}{}", style.paint(BOLD, &location), style.paint(DIM, &rule), finding.message diff --git a/src/report.html b/src/report.html index 6754228..73a7466 100644 --- a/src/report.html +++ b/src/report.html @@ -17,6 +17,7 @@

+

@@ -59,6 +60,8 @@ const cost=el('div');cost.append(el('strong',data.cost?.estimated_usd!==undefined?'$'+data.cost.estimated_usd.toFixed(4):'Unavailable'),el('span','estimated batch cost'));$('stats').append(cost); $('cost-basis').textContent=data.cost?'USD estimate for this invocation: $'+data.cost.input_per_million+' per million input tokens; output tokens free. Cached results incur no new inference cost. Pricing checked '+data.cost.checked_at+'.':'No published rate configured for this model.'; if(data.selected.length)$('selection').textContent='Rules checked: '+data.selected.map(name).join(', ')+'.'+(data.partial?' Only these rules were selected (--rule, --skip-rule or [rules]), so files that only other rules judge are not listed.':''); +const mature=Object.entries(data.fail_on_mature||{}),measuring=findings.filter(f=>f.gate==='measuring').length; +if(data.fail_on.includes('mature')||mature.length)$('gate-policy').textContent='Gate: by default only rules and levels right at least 80% of the time on projects JevGate was never tuned on fail it'+(mature.length?' ('+mature.map(([rule,levels])=>name(rule)+' '+levels.map(l=>l+'s').join(' and ')).join(', ')+')':'')+'.'+(measuring?' '+measuring+(measuring===1?' finding':' findings')+' of rules still being measured '+(measuring===1?'is':'are')+' reported without failing it; --fail-on review makes every review fail it.':''); for(const error of data.errors)p($('errors'),error); if(data.deleted.length)p($('errors'),'Deleted files (no current code reviewed): '+data.deleted.join(', '),'small'); let saved={};try{saved=JSON.parse(sessionStorage.getItem('jevgate:'+data.root)||'{}')}catch{} @@ -70,12 +73,16 @@ function save(){try{sessionStorage.setItem('jevgate:'+data.root,JSON.stringify({query:$('search').value,filter:$('filter').value,open:[...openPaths],page,pageSize:Number($('page-size').value),scroll:window.scrollY}))}catch{}} const order=['review','consider','note','error','needs-context','uncertain','pending','clear','not-applicable','skipped']; const files=[...data.files].sort((a,b)=>order.indexOf(a.status)-order.indexOf(b.status)||a.path.localeCompare(b.path)); +function stillMeasured(finding){const unseen=rules.get(finding.rule)?.maturity?.[finding.strength]?.unseen;const right=unseen&&unseen.labeled?Math.round(100*unseen.right/unseen.labeled)+'% of '+unseen.labeled+' right':'none labeled yet';return 'Does not fail the gate: '+name(finding.rule)+' '+finding.strength+'s are still being measured ('+right+' on projects JevGate was never tuned on).'} function findingRow(finding){ const row=el('details',null,'row finding-row'),summary=el('summary'),main=el('span',null,'main'); main.append(el('span','Line '+finding.line+(finding.baselined?' · baselined':finding.suppressed?' · allowed: '+finding.suppressed:''),'path'),el('span',finding.message,'lead')); - summary.append(main,el('span',name(finding.rule),'count'),badge(finding.strength));row.append(summary); + summary.append(main,el('span',name(finding.rule),'count')); + if(finding.gate==='fails')summary.append(el('span','fails the gate','badge review')); + summary.append(badge(finding.strength));row.append(summary); const body=el('div',null,'detail');row.append(body); p(body,finding.action); + if(finding.gate==='measuring')p(body,stillMeasured(finding),'small'); p(body,finding.category,'small'); if(finding.locations&&finding.locations.length>1)p(body,'Locations: '+finding.locations.map(l=>l.path+':'+l.start_line+'–'+l.end_line).join(', '),'small'); p(body,'Concern '+pct(finding.concern_probability),'small'); diff --git a/src/sarif.rs b/src/sarif.rs index f9c1649..5b9f9f1 100644 --- a/src/sarif.rs +++ b/src/sarif.rs @@ -1,9 +1,7 @@ //! `--format sarif`: the findings as a SARIF 2.1.0 log, for GitHub code //! scanning, GitLab and editors that read static analysis results. use crate::{ - catalog, - options::CheckArgs, - output, + catalog, output, schema::{Finding, Report, Status, Strength}, }; use anyhow::Result; @@ -14,30 +12,26 @@ const SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json"; const HOME: &str = "https://github.com/Tech-Byte-Frontier/jevgate"; /// The same findings the GitHub annotations show: every new finding that is -/// not a note, an `error` when it fails the gate and a `warning` otherwise. -/// Run errors and files that could not be judged are tool notifications. -pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<()> { +/// not a note, an `error` when it fails the gate and a `warning` otherwise, +/// with how the gate counted it as the `gate` property. Run errors and files +/// that could not be judged are tool notifications. +pub fn emit(out: &mut impl Write, report: &Report) -> Result<()> { let shown: Vec<(&Path, &Finding)> = output::ranked(report) .into_iter() .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) .collect(); - serde_json::to_writer_pretty(&mut *out, &document(report, &shown, args))?; + serde_json::to_writer_pretty(&mut *out, &document(report, &shown))?; writeln!(out)?; Ok(()) } -fn document(report: &Report, shown: &[(&Path, &Finding)], args: &CheckArgs) -> Value { +fn document(report: &Report, shown: &[(&Path, &Finding)]) -> Value { let rules = catalog::rules(); let results: Vec = shown .iter() .map(|(path, finding)| { let index = rules.iter().position(|r| r.id == finding.rule); - result( - path, - finding, - index, - crate::gate::fails(finding, path, args), - ) + result(path, finding, index) }) .collect(); let mut notifications: Vec = report @@ -101,7 +95,7 @@ fn title(id: &str) -> String { }) } -fn result(path: &Path, finding: &Finding, rule_index: Option, fails: bool) -> Value { +fn result(path: &Path, finding: &Finding, rule_index: Option) -> Value { let end = finding .locations .iter() @@ -122,10 +116,14 @@ fn result(path: &Path, finding: &Finding, rule_index: Option, fails: bool }) }) .collect(); + let mut text = format!("{}\n\nNext step: {}", finding.message, finding.action); + if let Some(note) = output::measuring_note(finding) { + text.push_str(&format!("\n\n{note}")); + } let mut value = json!({ "ruleId": finding.rule, - "level": if fails { "error" } else { "warning" }, - "message": {"text": format!("{}\n\nNext step: {}", finding.message, finding.action)}, + "level": if finding.fails_gate() { "error" } else { "warning" }, + "message": {"text": text}, "locations": [{"physicalLocation": { "artifactLocation": artifact(path), "region": {"startLine": finding.line.max(1), "endLine": end.max(1)}, @@ -145,6 +143,9 @@ fn result(path: &Path, finding: &Finding, rule_index: Option, fails: bool if let Some(category) = &finding.category { value["properties"]["category"] = json!(category); } + if let Some(gate) = finding.gate { + value["properties"]["gate"] = json!(gate); + } value } @@ -156,7 +157,7 @@ fn artifact(path: &Path) -> Value { #[cfg(test)] mod tests { use super::*; - use crate::tests::finding; + use crate::{options::CheckArgs, schema::Gating, tests::finding}; fn report(args: &CheckArgs) -> Report { crate::evaluate::snapshot( @@ -174,20 +175,31 @@ mod tests { #[test] fn results_name_their_rule_level_location_and_fingerprint() { let args = crate::tests::args(); - let review = finding(Strength::Review); - let consider = finding(Strength::Consider); + let review = Finding { + gate: Some(Gating::Fails), + ..finding(Strength::Review) + }; + let measuring = Finding { + gate: Some(Gating::Measuring), + ..finding(Strength::Review) + }; let path = Path::new("src/a,b.rs"); - let log = document(&report(&args), &[(path, &review), (path, &consider)], &args); + let log = document(&report(&args), &[(path, &review), (path, &measuring)]); assert_eq!(log["version"], "2.1.0"); let run = &log["runs"][0]; let rules = run["tool"]["driver"]["rules"].as_array().unwrap(); assert_eq!(rules.len(), catalog::rules().len()); let results = run["results"].as_array().unwrap(); - assert_eq!( - results[0]["level"], "error", - "a review fails the default gate" - ); + assert_eq!(results[0]["level"], "error", "a review that fails the gate"); + assert_eq!(results[0]["properties"]["gate"], "fails"); assert_eq!(results[1]["level"], "warning"); + assert_eq!(results[1]["properties"]["gate"], "measuring"); + assert!( + results[1]["message"]["text"] + .as_str() + .unwrap() + .ends_with("reviews are still being measured (54% of 85 right on projects JevGate was never tuned on).") + ); let first = &results[0]; let index = first["ruleIndex"].as_u64().unwrap() as usize; assert_eq!(rules[index]["id"], "maintainability/shared-logic"); @@ -209,7 +221,7 @@ mod tests { #[test] fn security_rules_are_tagged_for_code_scanning() { let args = crate::tests::args(); - let log = document(&report(&args), &[], &args); + let log = document(&report(&args), &[]); let rules = log["runs"][0]["tool"]["driver"]["rules"] .as_array() .unwrap(); diff --git a/src/schema/report.rs b/src/schema/report.rs index d742dde..c63943f 100644 --- a/src/schema/report.rs +++ b/src/schema/report.rs @@ -122,6 +122,10 @@ pub struct Finding { /// finding is accepted as a baselined one is. #[serde(default, skip_serializing_if = "Option::is_none")] pub suppressed: Option, + /// How the gate counted it: none for notes and accepted findings, and + /// before the gate is applied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate: Option, } impl Finding { @@ -129,6 +133,26 @@ impl Finding { pub fn accepted(&self) -> bool { self.baselined || self.suppressed.is_some() } + + /// Whether the gate counted it as a failure. + pub fn fails_gate(&self) -> bool { + self.gate == Some(Gating::Fails) + } +} + +/// How the gate counted a new finding, at the level in force for its rule +/// and path. Set even when the run is incomplete and the gate not evaluated. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Gating { + /// It fails the gate. + Fails, + /// Reported without failing: the level is `mature`, and this rule and + /// level is still being measured. + Measuring, + /// Reported without failing: the level does not count it, as `review` + /// does not count a consider and `none` counts nothing. + Advisory, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -228,6 +252,11 @@ pub struct Report { /// Gate levels for the files `[[scope]]` paths match, in configuration order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fail_on_paths: Vec, + /// What the `mature` level stands for: the levels of each selected rule + /// measured right at least 80% of the time on projects JevGate was never + /// tuned on, by rule ID, for the rules whose levels include `mature`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub fail_on_mature: BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] pub gate: Option, pub api_requests: u32, diff --git a/src/tests/gating.rs b/src/tests/gating.rs index af1ff39..979df07 100644 --- a/src/tests/gating.rs +++ b/src/tests/gating.rs @@ -15,7 +15,7 @@ fn gate_fails_only_on_the_configured_results() { ]; for (level, status, default, stricter, stricter_code) in cases { options.refresh = true; - options.fail_on = vec![options::FailOn::Review]; + options.fail_on = vec![options::FailOn::Mature]; let mut mock = Mock { level, ..Default::default() @@ -60,6 +60,18 @@ fn a_rule_level_fails_the_gate_only_for_that_rule() { } } +/// A `[[scope]]` that sets `levels` for every rule in the files `glob` matches. +fn every_rule_in(glob: &str, levels: Vec) -> options::PathLevels { + options::PathLevels { + paths: vec![glob.into()], + matcher: crate::boundary::globs(&[glob.into()]).unwrap(), + rules: crate::catalog::keys() + .into_iter() + .map(|key| (key.to_string(), levels.clone())) + .collect(), + } +} + #[test] fn a_scope_makes_its_paths_report_only_while_other_files_gate() { let project = Project::new(); @@ -71,14 +83,7 @@ fn a_scope_makes_its_paths_report_only_while_other_files_gate() { level: 4, ..Default::default() }; - let scripts = |levels: Vec| options::PathLevels { - paths: vec!["scripts/**".into()], - matcher: crate::boundary::globs(&["scripts/**".into()]).unwrap(), - rules: crate::catalog::keys() - .into_iter() - .map(|key| (key.to_string(), levels.clone())) - .collect(), - }; + let scripts = |levels| every_rule_in("scripts/**", levels); options.path_fail_on = vec![scripts(vec![options::FailOn::None])]; let report = run(&project, &options, &mut mock); let gate = report.gate.as_ref().unwrap(); @@ -241,3 +246,129 @@ fn an_allow_comment_accepts_a_finding_only_with_a_reason() { ); } } + +const SIMPLIFICATION: &str = "maintainability/function-simplification"; +const SHARED_LOGIC: &str = "maintainability/shared-logic"; + +/// How the gate counted each finding of a report, in order. +fn gates(report: &schema::Report) -> Vec> { + report.files[0].findings.iter().map(|f| f.gate).collect() +} + +#[test] +fn the_default_gate_fails_only_on_mature_rule_levels() { + use schema::{Gating::*, Strength::*}; + let accepted = schema::Finding { + baselined: true, + ..finding_of(SIMPLIFICATION, Review) + }; + let findings = vec![ + finding_of(SIMPLIFICATION, Review), + finding_of(SIMPLIFICATION, Consider), + finding_of(SHARED_LOGIC, Review), + finding_of(SIMPLIFICATION, Note), + accepted, + ]; + let report = gated(findings.clone(), &args()); + assert_eq!( + gates(&report), + [Some(Fails), Some(Measuring), Some(Measuring), None, None] + ); + assert_eq!( + report.gate.as_ref().unwrap().reasons, + ["1 new review finding"] + ); + assert_eq!(gate::exit_code(&report), 1); + let measured = gated(findings[1..].to_vec(), &args()); + let gate = measured.gate.as_ref().unwrap(); + assert!(gate.passed, "reviews still being measured are reported"); + assert_eq!((gate.new_findings, gate.baselined_findings), (2, 1)); + assert_eq!(gate::exit_code(&measured), 0); +} + +#[test] +fn an_explicit_level_replaces_the_default_exactly_as_it_says() { + use schema::{Gating::*, Strength::*}; + let findings = vec![ + finding_of(SIMPLIFICATION, Review), + finding_of(SHARED_LOGIC, Review), + finding_of(SHARED_LOGIC, Consider), + ]; + let mut options = args(); + options.fail_on = vec![options::FailOn::Review]; + let report = gated(findings.clone(), &options); + assert_eq!(gates(&report), [Some(Fails), Some(Fails), Some(Advisory)]); + assert_eq!(report.gate.unwrap().reasons, ["2 new review findings"]); + // A level for one rule leaves the others at the default. + let mut options = args(); + options.rule_fail_on = std::collections::BTreeMap::from([( + crate::catalog::SHARED_LOGIC.to_string(), + vec![options::FailOn::None], + )]); + let report = gated(findings.clone(), &options); + assert_eq!( + gates(&report), + [Some(Fails), Some(Advisory), Some(Advisory)] + ); + // A scope's report level covers the mature rule too. + let mut options = args(); + options.path_fail_on = vec![every_rule_in("src/**", vec![options::FailOn::None])]; + let report = gated(findings, &options); + assert_eq!(gates(&report), [Some(Advisory); 3]); + assert_eq!(gate::exit_code(&report), 0); +} + +#[test] +fn agent_text_marks_what_fails_and_says_why_the_rest_did_not() { + use schema::Strength::*; + let report = gated( + vec![ + finding_of(SIMPLIFICATION, Review), + finding_of(SHARED_LOGIC, Review), + finding_of(SHARED_LOGIC, Consider), + ], + &args(), + ); + let mut out = Vec::new(); + output::agent(&mut out, &report, false, output::Style::PLAIN).unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!( + text.contains("[maintainability/function-simplification] (fails the gate) Copies"), + "{text}" + ); + assert!( + text.contains("[maintainability/shared-logic] Copies"), + "{text}" + ); + assert!( + text.contains("\n1 review and 1 consider did not fail the gate: by default only rules and levels right at least 80% of the time on projects JevGate was never tuned on fail it, and theirs are still being measured."), + "{text}" + ); + let considers_only = gated(vec![finding_of(SHARED_LOGIC, Consider)], &args()); + assert!( + output::measuring(&considers_only).is_none(), + "considers never failed by default" + ); +} + +#[test] +fn a_capped_list_shows_the_findings_that_fail_the_gate_first() { + use schema::Strength::*; + let failing = schema::Finding { + rank: 0.1, + ..finding_of("documentation/agent-context", Consider) + }; + let mut findings = vec![finding_of(SHARED_LOGIC, Consider); 11]; + findings.push(failing); + let report = gated(findings, &args()); + let mut out = Vec::new(); + output::agent(&mut out, &report, false, output::Style::PLAIN).unwrap(); + let text = String::from_utf8(out).unwrap(); + let section = text.split("Consider (").nth(1).unwrap(); + assert!( + section.starts_with( + "12, top 10, those that fail the gate first; --verbose shows all):\n src/lib.rs:12 [documentation/agent-context] (fails the gate)" + ), + "{text}" + ); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 278bb74..d7ff342 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -37,11 +37,10 @@ struct TestCli { pub(super) fn args() -> CheckArgs { let mut a = TestCli::parse_from(["test"]).args; a.rules = crate::catalog::keys().into_iter().map(Into::into).collect(); - a.fail_on = vec![options::FailOn::Review]; + a.fail_on = vec![options::FailOn::Mature]; a } -/// A function large enough to judge (five body lines). /// A shared-logic finding at `src/a,b.rs:12`, with text the output formats escape. pub(super) fn finding(strength: crate::schema::Strength) -> crate::schema::Finding { crate::schema::Finding { @@ -66,9 +65,31 @@ pub(super) fn finding(strength: crate::schema::Strength) -> crate::schema::Findi rank: 1.0, baselined: false, suppressed: None, + gate: None, + } +} + +/// A finding of `rule` (an ID) at `strength`, otherwise as [`finding`]. +pub(super) fn finding_of(rule: &str, strength: crate::schema::Strength) -> crate::schema::Finding { + crate::schema::Finding { + rule: rule.into(), + ..finding(strength) } } +/// A complete report of one judged file, `src/lib.rs`, holding `findings`, +/// with the gate applied as `options` set it. +pub(super) fn gated(findings: Vec, options: &CheckArgs) -> schema::Report { + let project = Project::new(); + project.write("src/lib.rs", &function("f")); + let (_, mut report) = snapshot(&project, options); + report.files[0].findings = findings; + report.complete = true; + gate::evaluate(&mut report, options); + report +} + +/// A function large enough to judge (five body lines). pub(super) fn function(name: &str) -> String { format!( "fn {name}(values: &[i32]) -> i32 {{\n let mut total = 0;\n for value in values {{\n total += value;\n }}\n let doubled = total * 2;\n doubled + 1\n}}\n" diff --git a/src/units/compose/comments.rs b/src/units/compose/comments.rs index 96fec33..a41a3ca 100644 --- a/src/units/compose/comments.rs +++ b/src/units/compose/comments.rs @@ -93,6 +93,7 @@ pub(super) fn comment_findings( rank: rank(p, lines), baselined: false, suppressed: None, + gate: None, } }) .collect() diff --git a/src/units/compose/mod.rs b/src/units/compose/mod.rs index 5e603cd..8d82636 100644 --- a/src/units/compose/mod.rs +++ b/src/units/compose/mod.rs @@ -687,5 +687,6 @@ fn finding( rank: rank(p, lines), baselined: false, suppressed: None, + gate: None, } } diff --git a/src/units/compose/redundant.rs b/src/units/compose/redundant.rs index 72397b1..2792e93 100644 --- a/src/units/compose/redundant.rs +++ b/src/units/compose/redundant.rs @@ -144,5 +144,6 @@ pub(super) fn group_finding(plan: &FilePlan, cluster: Cluster<'_>) -> Finding { rank: rank(p, lines), baselined: false, suppressed: None, + gate: None, } } diff --git a/tests/cli/rules.rs b/tests/cli/rules.rs index fabdaec..efb3724 100644 --- a/tests/cli/rules.rs +++ b/tests/cli/rules.rs @@ -39,6 +39,21 @@ fn catalog_and_cli_expose_only_the_supported_maintainability_checks() { "comments" ] ); + let rule = |key: &str| { + rules + .as_array() + .unwrap() + .iter() + .find(|r| r["key"] == key) + .unwrap() + }; + let simplification = &rule("function_simplification")["maturity"]; + assert_eq!(simplification["review"]["mature"], true); + assert_eq!(simplification["consider"]["mature"], false); + assert_eq!( + simplification["review"]["unseen"], + serde_json::json!({"right": 20, "labeled": 23}) + ); for arguments in [ vec!["check", "--rule", "contracts", "--dry-run"], vec!["record"], @@ -61,6 +76,25 @@ fn rules_table_names_rules_groups_and_opt_in_rules() { "{table}" ); assert!(table.contains("security/injection") && table.contains("opt-in")); + let row = |id: &str| { + table + .lines() + .find(|line| line.starts_with(id)) + .unwrap() + .split_whitespace() + .take(9) + .collect::>() + .join(" ") + }; + assert_eq!( + row("maintainability/function-simplification"), + "maintainability/function-simplification yes review 87% of 23 67% of 126" + ); + assert_eq!( + row("maintainability/hardcoded-values"), + "maintainability/hardcoded-values yes - 13% of 8 17% of 29" + ); + assert!(table.contains("BLOCKS: the levels that fail the check by default")); } #[test] @@ -120,11 +154,16 @@ fn skipped_rules_and_rule_levels_shape_the_run() { ], ); assert_eq!(stages(&preview), ["functions"]); - assert_eq!(preview["fail_on"], serde_json::json!(["review"])); + assert_eq!(preview["fail_on"], serde_json::json!(["mature"])); assert_eq!( preview["fail_on_rules"], serde_json::json!({"maintainability/shared-logic": ["consider"]}) ); + assert_eq!( + preview["fail_on_mature"], + serde_json::json!({"maintainability/function-simplification": ["review"]}), + "what `mature` stands for among the selected rules" + ); } #[test] From abe1bc943faa380ffbfc971cb0b249e03ff393d1 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:40:47 -0300 Subject: [PATCH 002/306] Leave hardcoded values out of the default rules On the 25 projects JevGate was never tuned on, 6 of its 37 labeled reviews and considers were right (16%), against 47 of 85 on the projects it was tuned on (55%). It stays in the maintainability group and in `all`; `--rule default --rule hardcoded-values` adds it back. Without it, a default run plans 44% fewer first-pass requests on the 94 labeled corpus projects (22,370 to 12,623) and uploads 39% fewer bytes; with `--rule all` every request body is unchanged. --- CHANGELOG.md | 3 ++- README.md | 2 +- site/generate.py | 2 +- site/src/configuration.md | 2 +- site/src/stability.md | 1 + site/src/what-it-finds.md | 4 ++-- src/catalog.rs | 5 ++++- src/config.rs | 2 ++ src/init.rs | 1 + src/main.rs | 8 ++++---- tests/cli/rules.rs | 14 ++++++++++++-- 11 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b39abd4..e4f4657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,10 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver | Code comments | - | - | 39/72 (54%) | 97/150 | - Any level you set replaces the default exactly as it says: `fail_on = ["review"]` or `--fail-on review` fails on every review, as before; `--fail-on security=consider` sets one group and leaves the others at `mature`, which can also be set by name (`security = "mature"` in `[rules]`). Undecided answers never fail the check under `mature`. - - A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`, which keeps every review of those groups failing the check; delete the two lines for the new default. `jevgate init` now writes each group as a commented example. + - A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`, which keeps every review of those groups failing the check and judges hardcoded values; delete the two lines for the new default. `jevgate init` now writes each group as a commented example. - The output says what fails. The agent text marks each finding that fails the gate `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured and how to make them fail it. A GitHub warning, a SARIF result and a GitLab issue for such a finding say so, with how often its rule and level were right. The JSON report records how the gate counted each new finding as `gate` (`fails`, `measuring` or `advisory`), and `fail_on_mature` says what `mature` stands for among the selected rules; the HTML report and the MCP server's `jevgate_findings` show both. - `jevgate rules` shows the levels that fail by default and how often each rule's reviews and considers were right on unseen projects, with the number labeled; `--format json` adds each level's labels on unseen and tuned projects as `maturity`. +- Hardcoded values no longer runs by default: 6 of its 37 labeled reviews and considers were right on projects JevGate was never tuned on (16%), against 47 of 85 on the projects it was tuned on (55%). Without it, a default run asks 44% fewer first-pass requests (22,370 to 12,623 on 94 corpus projects) and uploads 39% fewer bytes. It stays in the `maintainability` group and in `all`; `--rule default --rule hardcoded-values` adds it to the default rules. ## [0.25.0] - 2026-09-27 diff --git a/README.md b/README.md index 2537c70..0109a6c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ JevGate 0.22.0 on [zoxide](https://github.com/ajeetdsouza/zoxide/tree/09a18b4424 | Group | Rules | On | |---|---|---| -| Maintainability | File organization, function simplification, shared logic, hardcoded values | by default | +| Maintainability | File organization, function simplification, shared logic; hardcoded values (opt-in) | by default | | Tests | Test value (mock-only checks, expected values recomputed with the code's own logic), test redundancy | with `--include-tests` | | Security | Injection, sensitive data, unsafe settings, SQL access control, GitHub workflows; each finding names a CWE | `--rule security` | | Documentation | Agent instruction files, large and stale docs, duplicated sections, code comments | `--rule documentation` | diff --git a/site/generate.py b/site/generate.py index 65a5430..aa0d82c 100644 --- a/site/generate.py +++ b/site/generate.py @@ -15,7 +15,7 @@ COMMANDS = ["auth", "check", "baseline", "rules", "init", "completions", "man", "serve", "mcp"] GROUPS = { - "maintainability": "On by default.", + "maintainability": "On by default, except hardcoded values: add it with `--rule default --rule hardcoded-values`, or a level for it in `[rules]`.", "tests": "On by default. Test value and test redundancy are judged with `--include-tests` or `include_tests = true`; the laws of Bend 2 code are judged without it.", "security": "Opt-in: `--rule security`, or a level in `[rules]`.", "documentation": "Opt-in: `--rule documentation`, or a level in `[rules]`.", diff --git a/site/src/configuration.md b/site/src/configuration.md index 6a68534..484926e 100644 --- a/site/src/configuration.md +++ b/site/src/configuration.md @@ -46,6 +46,6 @@ The default level, `mature`, fails the check only on the rules and levels measur Any level you set replaces the default exactly as it says, for the rules and paths it addresses: `fail_on = ["review"]` (or `--fail-on review`) fails on every review, as releases before 0.26 did; `--fail-on security=consider` sets one group and leaves the others at `mature`; `mature` itself can be set, such as for one group after a stricter `fail_on`. A later release can mark more levels mature as labels accumulate, or fewer; set `fail_on` to keep a fixed policy. Undecided answers never fail the check under `mature`. -A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`: those lines keep every review of the two groups failing the check. Delete them for the default gate. +A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`: those lines keep every review of the two groups failing the check, and judge hardcoded values. Delete them for the default rules and gate. The [configuration reference](reference/configuration.md) lists every key with its type, and the rule names and levels it accepts. diff --git a/site/src/stability.md b/site/src/stability.md index 67ee08c..d51e8ea 100644 --- a/site/src/stability.md +++ b/site/src/stability.md @@ -25,6 +25,7 @@ These are judgments or presentation, and any release can change them; the change - **Which findings a rule reports**, their levels, wording, probabilities and next steps. Findings are model judgments composed by code, and improving them is most of what releases do. A rule's `version` changes when its questions or composition change, and its cached answers are asked again. - **Which rules and levels fail the check by default.** The default level, `mature`, follows the labeled findings: a release marks a rule's reviews or considers mature once they are right at least 80% of the time on projects JevGate was never tuned on, over at least 20 labels, and can drop one that stops measuring up. The changelog gives the numbers. Set `fail_on`, or a level per rule, to keep a fixed policy. +- **Which rules run by default.** A rule can leave the default group, as hardcoded values did in 0.26, or join it; `--rule` and `[rules]` keep an explicit selection. - **The agent text** (`--format agent`): it is written for people and coding agents to read. Scripts should read JSON. - **Request bodies and the answer cache**: the cache is safe to delete or restore at any version; unmatched entries are simply not used. - **The default model**: a release can pin a newer model version, which re-asks every unit once. Set `model` in `jevgate.toml` to keep one. diff --git a/site/src/what-it-finds.md b/site/src/what-it-finds.md index 1657a10..b76ed84 100644 --- a/site/src/what-it-finds.md +++ b/site/src/what-it-finds.md @@ -1,13 +1,13 @@ # What it finds -**Maintainability** (on by default) +**Maintainability** (on by default, except hardcoded values) | Rule | Example finding | |---|---| | File organization | This file holds several features that would be easier to find apart; the upload helpers would be most useful as their own module. Test files are judged too, at most as a consider. | | Function simplification | `sync_accounts` mixes separate jobs in long blocks; lines 40–71 would be most useful as their own function. | | Shared logic | `createInvoice` and `createReceipt` perform the same steps; one shared implementation would serve both. | -| Hardcoded values | Module constants fix a value that differs between deployments; `apply_discount` special-cases one specific customer. | +| Hardcoded values (opt-in: `--rule default --rule hardcoded-values`) | Module constants fix a value that differs between deployments; `apply_discount` special-cases one specific customer. On projects JevGate was never tuned on, 6 of its 37 labeled findings were right, against 47 of 85 on the projects it was tuned on, so it no longer runs by default. | **Tests** (with `--include-tests`; file organization judges test files without it, and the laws of Bend 2 code are judged where they are) diff --git a/src/catalog.rs b/src/catalog.rs index 4b88c0a..e1c3f2c 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -89,7 +89,10 @@ pub fn rules() -> Vec { Rule { id: "maintainability/hardcoded-values", group: "maintainability", - default_enabled: true, + // Opt-in: 6 of its 37 labeled reviews and considers were right on + // projects JevGate was never tuned on (16%), against 47 of 85 on + // the projects it was tuned on (55%). + default_enabled: false, key: HARDCODED_VALUES, version: rule_version(HARDCODED_VALUES), scope: "application functions and module constants that use literal values other than 0, 1, 2 or one-character strings", diff --git a/src/config.rs b/src/config.rs index 2fce090..131371c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -444,6 +444,7 @@ mod tests { fn default_group_runs_when_nothing_is_configured() { let args = configured("", &[], &[]).unwrap(); assert_eq!(args.rules, catalog::select(catalog::DEFAULT_GROUP).unwrap()); + assert!(!args.rules.iter().any(|r| r == catalog::HARDCODED_VALUES)); assert_eq!(args.fail_on, [FailOn::Mature]); assert!(args.rule_fail_on.is_empty()); } @@ -463,6 +464,7 @@ mod tests { &[], ) .unwrap(); + assert!(!args.rules.iter().any(|r| r == catalog::HARDCODED_VALUES)); assert_eq!(args.fail_on, [FailOn::Consider]); assert_eq!(args.levels(catalog::SHARED_LOGIC), [FailOn::Review]); assert_eq!(args.levels(catalog::TEST_REDUNDANCY), [FailOn::None]); diff --git a/src/init.rs b/src/init.rs index 5f42b5e..7f16a54 100644 --- a/src/init.rs +++ b/src/init.rs @@ -204,6 +204,7 @@ mod tests { Rules::List(_) => panic!("rules is a table of levels"), }; assert!(levels(config).is_empty(), "the default rules and gate"); + assert!(text.contains("hardcoded-values (opt-in)"), "{text}"); let uncommented: Vec<&str> = text .lines() .map(|line| { diff --git a/src/main.rs b/src/main.rs index f24758c..5e40111 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,10 +77,10 @@ use options::JevCommand; /// has a location, a probability and a next step, and undecided answers are /// reported as uncertain instead of hidden. /// -/// Rule groups: maintainability (on by default), tests (with -/// --include-tests), and the opt-in security and documentation groups. By -/// default only rules and levels measured right at least 80% of the time on -/// projects JevGate was never tuned on fail the check. +/// Rule groups: maintainability (on by default, except hardcoded values), +/// tests (with --include-tests), and the opt-in security and documentation +/// groups. By default only rules and levels measured right at least 80% of +/// the time on projects JevGate was never tuned on fail the check. #[derive(Parser)] #[command(version, after_long_help = options::OVERVIEW)] pub struct Cli { diff --git a/tests/cli/rules.rs b/tests/cli/rules.rs index efb3724..354926d 100644 --- a/tests/cli/rules.rs +++ b/tests/cli/rules.rs @@ -47,6 +47,7 @@ fn catalog_and_cli_expose_only_the_supported_maintainability_checks() { .find(|r| r["key"] == key) .unwrap() }; + assert_eq!(rule("hardcoded_values")["default_enabled"], false); let simplification = &rule("function_simplification")["maturity"]; assert_eq!(simplification["review"]["mature"], true); assert_eq!(simplification["consider"]["mature"], false); @@ -92,7 +93,7 @@ fn rules_table_names_rules_groups_and_opt_in_rules() { ); assert_eq!( row("maintainability/hardcoded-values"), - "maintainability/hardcoded-values yes - 13% of 8 17% of 29" + "maintainability/hardcoded-values opt-in - 13% of 8 17% of 29" ); assert!(table.contains("BLOCKS: the levels that fail the check by default")); } @@ -141,7 +142,16 @@ fn skipped_rules_and_rule_levels_shape_the_run() { JUDGED_RS.replace("total * 2", "total * 86400"), ) .unwrap(); - assert!(stages(&dry_run(&project, &[])).contains(&"values".to_string())); + let values = "values".to_string(); + assert!( + !stages(&dry_run(&project, &[])).contains(&values), + "hardcoded values is opt-in" + ); + let opted_in = dry_run( + &project, + &["--rule", "default", "--rule", "hardcoded-values"], + ); + assert!(stages(&opted_in).contains(&values)); let preview = dry_run( &project, &[ From 199c9bce0a0678f2d904d8b11f609bed667ba313 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:23:44 -0300 Subject: [PATCH 003/306] Treat a model name without an x.y.z version as an alias, and accept gateway names Gateways name Jev `typesafe/jev-1.13`, `~typesafe/jev-latest` and `typesafe-ai/jev`. JevGate took any name but `jev-latest` and `jev-preview` for a pinned version, whose cached answers never expire and whose answer must name exactly that model, and its model check refused the `/` and `~` in an answering model's name: every gateway answer would have failed. A name is pinned only when its last segment ends in an x.y.z version and no `~` marks it as moving; a pinned request accepts its own version with or without a gateway's namespace. The default `jev-1.13.0` is unchanged, so nothing is asked again. --- CHANGELOG.md | 1 + jevgate.schema.json | 2 +- site/src/configuration.md | 2 +- src/config.rs | 2 +- src/main.rs | 1 + src/model.rs | 86 +++++++++++++++++++++++++++++++++++++++ src/options/mod.rs | 9 ++-- src/requests.rs | 6 ++- src/response.rs | 67 +++++++++++++++++++++++++----- 9 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 src/model.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f4657..5cd54fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver - The output says what fails. The agent text marks each finding that fails the gate `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured and how to make them fail it. A GitHub warning, a SARIF result and a GitLab issue for such a finding say so, with how often its rule and level were right. The JSON report records how the gate counted each new finding as `gate` (`fails`, `measuring` or `advisory`), and `fail_on_mature` says what `mature` stands for among the selected rules; the HTML report and the MCP server's `jevgate_findings` show both. - `jevgate rules` shows the levels that fail by default and how often each rule's reviews and considers were right on unseen projects, with the number labeled; `--format json` adds each level's labels on unseen and tuned projects as `maturity`. - Hardcoded values no longer runs by default: 6 of its 37 labeled reviews and considers were right on projects JevGate was never tuned on (16%), against 47 of 85 on the projects it was tuned on (55%). Without it, a default run asks 44% fewer first-pass requests (22,370 to 12,623 on 94 corpus projects) and uploads 39% fewer bytes. It stays in the `maintainability` group and in `all`; `--rule default --rule hardcoded-values` adds it to the default rules. +- Models: a model name without an `x.y.z` version is an alias, whose cached answers expire after `cache_ttl_secs`, and gateways' names are accepted (`typesafe/jev-1.13`, `~typesafe/jev-latest`, `typesafe-ai/jev`). TypeSafe's docs name models `jev` and `jev-1.13`; JevGate took those for pinned versions and rejected every answer, which names the version (`jev-1.13.0`), as coming from "a different pinned model". A pinned name still accepts only its own version, with or without a gateway's namespace. The default `jev-1.13.0` asks nothing again. ## [0.25.0] - 2026-09-27 diff --git a/jevgate.schema.json b/jevgate.schema.json index 9d9afa4..2e4ffb4 100644 --- a/jevgate.schema.json +++ b/jevgate.schema.json @@ -263,7 +263,7 @@ "description": "JevGate configuration. The command line wins over the file, except that upload patterns and budgets in the file are ceilings that flags can only narrow. Unknown keys are errors.", "properties": { "cache_ttl_secs": { - "description": "Cache lifetime in seconds for the `jev-latest` and `jev-preview` aliases; pinned versions never expire. Default: 3600.", + "description": "Cache lifetime in seconds for an alias, a model name without an x.y.z version such as `jev-latest`; pinned versions never expire. Default: 3600.", "minimum": 0, "type": "integer" }, diff --git a/site/src/configuration.md b/site/src/configuration.md index 484926e..ff3ea88 100644 --- a/site/src/configuration.md +++ b/site/src/configuration.md @@ -32,7 +32,7 @@ rules = { security = "consider" } # except these | `fail_on` | `["mature"]` | The level for rules without their own, like `--fail-on` | | `include_tests` | `false` | Judge tests, like `--include-tests` | | `model` | `jev-1.13.0` | TypeSafe model; a pinned version keeps results repeatable | -| `cache_ttl_secs` | `3600` | Cache lifetime for the `jev-latest` and `jev-preview` aliases; pinned versions never expire | +| `cache_ttl_secs` | `3600` | Cache lifetime for an alias: a model name without an `x.y.z` version, such as `jev-latest` or `jev-1.13`. Pinned versions such as `jev-1.13.0` never expire | | `max_requests` | unlimited | Ceiling on API attempts per invocation | | `concurrency` | `6` | Ceiling on simultaneous requests (1–8) | | `max_file_bytes` | `262144` | Files larger than this are reported as needs-context, never truncated; generated and vendored files are skipped instead | diff --git a/src/config.rs b/src/config.rs index 131371c..9f1384d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,7 +37,7 @@ pub struct Config { pub fail_on: Vec, /// TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it. pub model: Option, - /// Cache lifetime in seconds for the `jev-latest` and `jev-preview` aliases; pinned versions never expire. Default: 3600. + /// Cache lifetime in seconds for an alias, a model name without an x.y.z version such as `jev-latest`; pinned versions never expire. Default: 3600. pub cache_ttl_secs: Option, /// Judge tests, like `--include-tests`. Default: false. pub include_tests: bool, diff --git a/src/main.rs b/src/main.rs index 5e40111..8696e2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,7 @@ mod locations; mod manual; mod maturity; mod mcp; +mod model; mod options; mod output; mod packages; diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..033def3 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,86 @@ +//! What a model name says: a pinned version, whose answers never change, or an +//! alias that can move to a new version. + +/// The longest model name accepted from a provider. +const MAX_NAME_BYTES: usize = 128; + +/// A name a provider may return: letters, digits and `-_.`, with `/` between a +/// gateway's namespace and the model (`typesafe/jev-1.13`) and `~` for +/// OpenRouter's moving aliases (`~typesafe/jev-latest`). +pub fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MAX_NAME_BYTES + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.~/".contains(&c)) +} + +/// The name without a gateway's namespace: `jev-1.13` of `typesafe/jev-1.13`. +pub fn base_name(name: &str) -> &str { + name.rsplit_once('/').map_or(name, |(_, base)| base) +} + +/// A pinned version: the base name ends in an `x.y.z` version, as in +/// `jev-1.13.0` or `typesafe/jev-1.13.0`, and no `~` marks it as moving. Every +/// other name is an alias that can move to a new version: `jev`, `jev-1.13` +/// and `jev-latest` in TypeSafe's docs, and the gateways' `typesafe-ai/jev` and +/// `~typesafe/jev-latest`. +pub fn pinned(name: &str) -> bool { + let version = base_name(name).rsplit('-').next().unwrap_or_default(); + let parts: Vec<&str> = version.split('.').collect(); + !name.contains('~') + && parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_names_ending_in_an_x_y_z_version_are_pinned() { + for name in ["jev-1.13.0", "typesafe/jev-1.13.0", "typesafe-ai/jev-2.0.1"] { + assert!(pinned(name), "{name}"); + } + for name in [ + "jev", + "jev-1.13", + "jev-latest", + "jev-preview", + "typesafe/jev-1.13", + "typesafe-ai/jev", + "~typesafe/jev-latest", + "~typesafe/jev-1.13.0", + "jev-1.13.0-rc1", + "jev-1..0", + "other-version", + ] { + assert!(!pinned(name), "{name}"); + } + } + + #[test] + fn gateway_names_are_valid_and_their_base_is_the_model() { + for name in [ + "jev-1.13.0", + "typesafe/jev-1.13", + "~typesafe/jev-latest", + "typesafe-ai/jev", + ] { + assert!(valid_name(name), "{name}"); + } + for name in [ + "", + "jev 1", + "jev\n", + "jev@1", + &"j".repeat(MAX_NAME_BYTES + 1), + ] { + assert!(!valid_name(name), "{name:?}"); + } + assert_eq!(base_name("typesafe/jev-1.13"), "jev-1.13"); + assert_eq!(base_name("jev-1.13.0"), "jev-1.13.0"); + } +} diff --git a/src/options/mod.rs b/src/options/mod.rs index 06a23f2..a11f4bb 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -95,7 +95,8 @@ const WATCH: &str = "Watch"; /// The model used when neither `--model` nor `model` in jevgate.toml names one. pub const DEFAULT_MODEL: &str = "jev-1.13.0"; -/// Cache lifetime for the `jev-latest` and `jev-preview` aliases, in seconds. +/// Cache lifetime for an alias (a model name without an `x.y.z` version, such +/// as `jev-latest`), in seconds. pub const DEFAULT_CACHE_TTL_SECS: u64 = 3600; #[derive(Args, Debug)] @@ -227,10 +228,10 @@ pub struct CheckArgs { /// Total bytes of --context files per request; context is never truncated #[arg(long, value_name = "BYTES", default_value_t = 32768, value_parser = clap::value_parser!(u64).range(1..=1048576), help_heading = BUDGETS)] pub max_context_bytes: u64, - /// Cache lifetime for the jev-latest and jev-preview aliases [default: 3600] + /// Cache lifetime for an alias, a model name without an x.y.z version such as jev-latest [default: 3600] /// - /// Answers from a pinned model version never expire. Also set by - /// `cache_ttl_secs` in jevgate.toml. + /// Answers from a pinned model version, such as jev-1.13.0, never + /// expire. Also set by `cache_ttl_secs` in jevgate.toml. #[arg(long, value_name = "SECONDS", help_heading = BUDGETS)] pub cache_ttl_secs: Option, /// Ignore cached answers for this invocation and ask again diff --git a/src/requests.rs b/src/requests.rs index 8c80916..8315d55 100644 --- a/src/requests.rs +++ b/src/requests.rs @@ -74,7 +74,7 @@ pub(super) fn judgment_key(request: &Value) -> String { /// Aliases move to new model versions, so their answers expire. A pinned /// version answers the same request the same way; its entries never expire. fn cache_ttl(model: &str, ttl: u64) -> Option { - matches!(model, "jev-latest" | "jev-preview").then_some(ttl) + (!crate::model::pinned(model)).then_some(ttl) } /// A valid, unexpired cached answer to `request`, read through `load`; none @@ -297,8 +297,12 @@ mod tests { store.save("old", &body, schema::now() - 7200).unwrap(); for (model, kept) in [ ("jev-1.13.0", true), + ("typesafe/jev-1.13.0", true), ("jev-latest", false), ("jev-preview", false), + ("jev-1.13", false), + ("typesafe/jev-1.13", false), + ("typesafe-ai/jev", false), ] { let ttl = cache_ttl(model, 3600); assert_eq!(store.load("old", ttl).is_some(), kept, "{model}"); diff --git a/src/response.rs b/src/response.rs index f80a9b6..d29d3b0 100644 --- a/src/response.rs +++ b/src/response.rs @@ -38,22 +38,21 @@ pub fn validate(response: &Value, request: &Value) -> Result<()> { Ok(()) } -/// A well-formed model name, equal to the pinned model when one was requested. +/// A well-formed model name; when a pinned version was requested, that +/// version, with or without a gateway's namespace (`typesafe-ai/jev-1.13.0` +/// asked, `jev-1.13.0` answered). An alias answers with whatever version it +/// points to. fn validate_model(response: &Value, request: &Value) -> Result<()> { let model = response["model"] .as_str() .context("Missing model identity")?; - ensure!( - !model.is_empty() - && model.len() <= 128 - && model - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c)), - "Invalid model identity" - ); - if let Some(requested) = request["model"].as_str() { + ensure!(crate::model::valid_name(model), "Invalid model identity"); + if let Some(requested) = request["model"] + .as_str() + .filter(|name| crate::model::pinned(name)) + { ensure!( - matches!(requested, "jev-latest" | "jev-preview") || model == requested, + crate::model::base_name(model) == crate::model::base_name(requested), "Provider returned a different pinned model" ); } @@ -175,3 +174,49 @@ fn typed_fields(answer: &Value, kind: &str) -> Value { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A one-question request for `requested`, and a valid answer from `answered`. + fn exchange(requested: &str, answered: &str) -> (Value, Value) { + let request = json!({"model": requested, "state": "x", + "questions": {"q": {"type": "noul", "instructions": "?"}}}); + let response = json!({"model": answered, "answers": {"q": {"type": "noul", "noul": 0.1}}, + "usage": {"input_tokens": 10, "output_tokens": 1}}); + (request, response) + } + + #[test] + fn an_alias_accepts_any_version_and_a_pinned_name_only_its_own() { + for (requested, answered) in [ + ("jev-1.13.0", "jev-1.13.0"), + ("jev-latest", "jev-1.13.0"), + ("jev-1.13", "jev-1.13.0"), + ("~typesafe/jev-latest", "typesafe/jev-1.13"), + ("typesafe/jev-1.13", "typesafe/jev-1.13"), + ("typesafe-ai/jev", "typesafe-ai/jev"), + ("typesafe-ai/jev-1.13.0", "jev-1.13.0"), + ] { + let (request, response) = exchange(requested, answered); + assert!( + validate(&response, &request).is_ok(), + "{requested} {answered}" + ); + } + for (requested, answered) in [ + ("jev-1.13.0", "jev-1.14.0"), + ("jev-1.13.0", "typesafe/jev-1.13"), + ("jev-latest", "jev 1.13.0"), + ("jev-latest", ""), + ] { + let (request, response) = exchange(requested, answered); + assert!( + validate(&response, &request).is_err(), + "{requested} {answered}" + ); + } + } +} From 655894f6b73194f73ee58ab870dc726ea304656d Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:29:09 -0300 Subject: [PATCH 004/306] Price a run by the model that answered it, and accept answers without usage The cost was priced only when the requested name was exactly `jev-1.13.0`, so a `jev-latest` run showed none although TypeSafe answers it with `jev-1.13.0`, and a response without `usage` failed validation. Gateways need not pass TypeSafe's `usage` through. Each paid answer is now billed to the model it names; an answer without usage is accepted, cached without it and counted as unmetered, which makes the cost unknown ("cost unknown" in the headline, `estimated_usd: null`) instead of $0, and keeps it out of the bytes-per-token calibration. The price table moves to `model.rs`, by model line (`jev-1.13` and its versions under any gateway namespace), checked against TypeSafe's models page on 2026-09-28. --- CHANGELOG.md | 1 + site/src/privacy-and-cost.md | 2 +- src/check.rs | 3 +- src/evaluate.rs | 14 ++++-- src/html_report.rs | 17 ++++--- src/model.rs | 63 ++++++++++++++++++++++++-- src/options/commands.rs | 5 +- src/output.rs | 21 ++------- src/requests.rs | 88 ++++++++++++++++++++++++++++-------- src/response.rs | 61 +++++++++++++++++++++---- src/schema/report.rs | 12 +++++ src/tests/mod.rs | 48 +++++++++++++++++++- 12 files changed, 267 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd54fa..2668b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver - `jevgate rules` shows the levels that fail by default and how often each rule's reviews and considers were right on unseen projects, with the number labeled; `--format json` adds each level's labels on unseen and tuned projects as `maturity`. - Hardcoded values no longer runs by default: 6 of its 37 labeled reviews and considers were right on projects JevGate was never tuned on (16%), against 47 of 85 on the projects it was tuned on (55%). Without it, a default run asks 44% fewer first-pass requests (22,370 to 12,623 on 94 corpus projects) and uploads 39% fewer bytes. It stays in the `maintainability` group and in `all`; `--rule default --rule hardcoded-values` adds it to the default rules. - Models: a model name without an `x.y.z` version is an alias, whose cached answers expire after `cache_ttl_secs`, and gateways' names are accepted (`typesafe/jev-1.13`, `~typesafe/jev-latest`, `typesafe-ai/jev`). TypeSafe's docs name models `jev` and `jev-1.13`; JevGate took those for pinned versions and rejected every answer, which names the version (`jev-1.13.0`), as coming from "a different pinned model". A pinned name still accepts only its own version, with or without a gateway's namespace. The default `jev-1.13.0` asks nothing again. +- Cost: a run is priced by the model that answered each request, not by the name it asked for. A `jev-latest` run showed no cost, although TypeSafe answers it with `jev-1.13.0`. A response without `usage`, which a gateway need not send, is accepted, and the run's cost is shown as unknown rather than $0; its tokens stay out of the bytes-per-token calibration. The JSON report adds `paid_models` (input tokens by the model that answered them), `unmetered_requests` and `estimated_usd` (null when unknown). ## [0.25.0] - 2026-09-27 diff --git a/site/src/privacy-and-cost.md b/site/src/privacy-and-cost.md index 8dcbcb9..e6aa074 100644 --- a/site/src/privacy-and-cost.md +++ b/site/src/privacy-and-cost.md @@ -4,5 +4,5 @@ - **Instruction files:** uploaded only when a documentation rule is selected, and still bounded by the upload patterns. - **README opening:** a sensitive-data finding about error details is asked who reads the error text, with the first 1,200 characters of the root README's prose (images, badges and HTML left out), unless `upload_deny` covers the README or `upload_allow` leaves it out. - **Credentials:** a check reads `TYPESAFE_API_KEY` from the environment, then `--env-file` or the repository's `.env`, then the key saved by `jevgate auth login` (OS credential store, or an owner-only file). The key is never printed or written to reports. -- **Cost:** every run prints its input tokens and an estimated cost. Cached answers cost nothing. +- **Cost:** every run prints its input tokens and an estimated cost, priced by the model that answered: Jev 1.13 costs $0.042 per million input tokens, and output is free. When a response reports no token usage, the cost is shown as unknown, never as $0. Cached answers cost nothing. - **Secrets:** out of scope on purpose, because judging secrets would mean uploading them. Use a local secret scanner. diff --git a/src/check.rs b/src/check.rs index 4a228fa..11a00db 100644 --- a/src/check.rs +++ b/src/check.rs @@ -89,8 +89,7 @@ pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { store: &store, evaluator: &mut client, requests: 0, - paid_input_tokens: 0, - paid_output_tokens: 0, + paid: Default::default(), budget: token_budget::TokenBudget::load(&context.root), observed: (0, 0), }; diff --git a/src/evaluate.rs b/src/evaluate.rs index 1c27e17..957f087 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -17,8 +17,8 @@ pub struct Session<'a> { pub store: &'a Store, pub evaluator: &'a mut dyn Evaluator, pub requests: u32, - pub paid_input_tokens: u64, - pub paid_output_tokens: u64, + /// What this invocation's requests were billed. + pub paid: crate::requests::Usage, pub budget: TokenBudget, /// Uploaded bytes and billed input tokens of fresh requests, for calibration. pub observed: (u64, u64), @@ -104,6 +104,9 @@ fn empty_report(args: &CheckArgs, current: &SnapshotContext<'_>, files: Vec { fn progress(&self, report: &mut Report) -> Result<()> { report.api_requests = self.requests; - report.paid_input_tokens = self.paid_input_tokens; - report.paid_output_tokens = self.paid_output_tokens; + report.paid_input_tokens = self.paid.input_tokens; + report.paid_output_tokens = self.paid.output_tokens; + report.paid_models = self.paid.models.clone(); + report.unmetered_requests = self.paid.unmetered; + report.estimated_usd = self.paid.usd(); self.verify_current(report); report.update_status(); self.publish(report) diff --git a/src/html_report.rs b/src/html_report.rs index 540ccd7..2aff0ee 100644 --- a/src/html_report.rs +++ b/src/html_report.rs @@ -13,12 +13,12 @@ fn dimension(rule: &str, d: &Dimension) -> Value { } fn batch_cost(report: &Report) -> Option { - crate::output::estimated_usd(report).map(|usd| { + report.estimated_usd.map(|usd| { json!({ "estimated_usd": usd, - "input_per_million": crate::output::INPUT_USD_PER_MILLION, + "input_per_million": crate::model::INPUT_USD_PER_MILLION, "output_per_million": 0.0, - "checked_at": crate::output::PRICE_CHECKED + "checked_at": crate::model::PRICE_CHECKED }) }) } @@ -162,13 +162,12 @@ mod tests { serde_json::json!({"right": 20, "labeled": 23}) ); assert!(!html.contains("id=\"root\"")); - report.paid_input_tokens = 1_000_000; - report.paid_output_tokens = 12_345; - let cost = batch_cost(&report).unwrap(); - assert!((cost["estimated_usd"].as_f64().unwrap() - 0.042).abs() < 1e-12); - report.paid_input_tokens = 0; assert_eq!(batch_cost(&report).unwrap()["estimated_usd"], 0.0); - report.requested_model = "unknown-model".into(); + report.estimated_usd = Some(0.042); + let cost = batch_cost(&report).unwrap(); + assert_eq!(cost["estimated_usd"], 0.042); + assert_eq!(cost["checked_at"], crate::model::PRICE_CHECKED); + report.estimated_usd = None; assert!(batch_cost(&report).is_none()); assert!(!html.contains("def value()")); } diff --git a/src/model.rs b/src/model.rs index 033def3..ba1a40a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,9 +1,22 @@ //! What a model name says: a pinned version, whose answers never change, or an -//! alias that can move to a new version. +//! alias that can move to a new version; and what its input costs. /// The longest model name accepted from a provider. const MAX_NAME_BYTES: usize = 128; +/// Jev 1.13's published price in dollars per million input tokens; output +/// tokens are free. TypeSafe's models page (https://docs.typesafe.ai/models), +/// checked on `PRICE_CHECKED`; OpenRouter's and Vercel AI Gateway's listings +/// give the same price. +pub const INPUT_USD_PER_MILLION: f64 = 0.042; +pub const PRICE_CHECKED: &str = "2026-09-28"; + +/// Model lines with a published price. A name is priced when its base name is +/// the line or one of its versions: `jev-1.13`, `jev-1.13.0`, +/// `typesafe/jev-1.13`. An alias that names no version, such as +/// `typesafe-ai/jev`, is not: its price follows whatever it points to. +const PRICED_LINES: [&str; 1] = ["jev-1.13"]; + /// A name a provider may return: letters, digits and `-_.`, with `/` between a /// gateway's namespace and the model (`typesafe/jev-1.13`) and `~` for /// OpenRouter's moving aliases (`~typesafe/jev-latest`). @@ -27,9 +40,28 @@ pub fn base_name(name: &str) -> &str { /// `~typesafe/jev-latest`. pub fn pinned(name: &str) -> bool { let version = base_name(name).rsplit('-').next().unwrap_or_default(); - let parts: Vec<&str> = version.split('.').collect(); - !name.contains('~') - && parts.len() == 3 + !name.contains('~') && dotted_numbers(version, 3) +} + +/// Estimated dollars for `tokens` input tokens answered by `name`; none when +/// its price is unknown. No tokens cost nothing, whatever the model. +pub fn usd(name: &str, tokens: u64) -> Option { + let base = base_name(name); + let priced = PRICED_LINES.iter().any(|line| { + base.strip_prefix(line).is_some_and(|rest| { + rest.is_empty() + || rest + .strip_prefix('.') + .is_some_and(|patch| dotted_numbers(patch, 1)) + }) + }); + (tokens == 0 || priced).then(|| tokens as f64 * INPUT_USD_PER_MILLION / 1_000_000.0) +} + +/// Whether `text` is `count` runs of digits joined by dots: `1.13.0` for three. +fn dotted_numbers(text: &str, count: usize) -> bool { + let parts: Vec<&str> = text.split('.').collect(); + parts.len() == count && parts .iter() .all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit())) @@ -83,4 +115,27 @@ mod tests { assert_eq!(base_name("typesafe/jev-1.13"), "jev-1.13"); assert_eq!(base_name("jev-1.13.0"), "jev-1.13.0"); } + + #[test] + fn the_jev_1_13_line_is_priced_under_any_namespace_and_aliases_are_not() { + for name in [ + "jev-1.13.0", + "jev-1.13", + "typesafe/jev-1.13", + "typesafe-ai/jev-1.13.2", + ] { + let usd = usd(name, 1_000_000).unwrap_or_else(|| panic!("{name}")); + assert!((usd - INPUT_USD_PER_MILLION).abs() < 1e-12, "{name}"); + } + for name in [ + "jev-latest", + "typesafe-ai/jev", + "~typesafe/jev-latest", + "jev-1.130", + "jev-1.13.x", + ] { + assert_eq!(usd(name, 1_000), None, "{name}"); + } + assert_eq!(usd("typesafe-ai/jev", 0), Some(0.0)); + } } diff --git a/src/options/commands.rs b/src/options/commands.rs index 9ac27d1..1b48e62 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -247,7 +247,10 @@ Reading the JSON report (--format json or .jevgate/latest.json): measured) or advisory (below the level in force) files[].dimensions per rule: status, unit counts and the units left undecided files[].judgments every raw answer, first pass and follow-ups - api_requests, paid_input_tokens, paid_output_tokens this run's usage"; + api_requests, paid_input_tokens, paid_output_tokens this run's usage + paid_models input tokens by the model that answered them + estimated_usd this run's cost; null when a response reported no usage + (unmetered_requests) or the model has no known price"; const COMPLETIONS_EXAMPLES: &str = "\ Examples: diff --git a/src/output.rs b/src/output.rs index a16bb9c..045387d 100644 --- a/src/output.rs +++ b/src/output.rs @@ -12,24 +12,14 @@ use std::{ /// Consider findings shown by default; `--verbose` shows all. const TOP_CONSIDER: usize = 10; -// Published Jev rate, checked 2026-09-18: -// https://typesafe.ai/blog/introducing-system-one-models-and-jev -pub const INPUT_USD_PER_MILLION: f64 = 0.042; -pub const PRICE_CHECKED: &str = "2026-09-18"; - /// `n` and a noun, plural unless `n` is one: "1 finding", "2 findings". pub fn count(n: usize, noun: &str) -> String { format!("{n} {noun}{}", if n == 1 { "" } else { "s" }) } -/// Estimated dollars for this invocation's paid input tokens, for a priced model. -pub fn estimated_usd(report: &Report) -> Option { - usd(&report.requested_model, report.paid_input_tokens) -} - -/// Estimated dollars for `tokens` input tokens of `model`, when it is priced. -fn usd(model: &str, tokens: u64) -> Option { - (model == "jev-1.13.0").then(|| tokens as f64 * INPUT_USD_PER_MILLION / 1_000_000.0) +/// The headline's cost: estimated dollars, or unknown, never a guessed $0. +fn cost(usd: Option) -> String { + usd.map_or(" · cost unknown".into(), |usd| format!(" · ~${usd:.4}")) } // ANSI select-graphic-rendition codes. @@ -161,8 +151,7 @@ pub(crate) fn headline(report: &Report) -> String { let planned: u64 = stages.clone().map(|s| s.planned_requests).sum(); let cached: u64 = stages.clone().map(|s| s.planned_cached).sum(); let tokens: u64 = stages.map(|s| s.planned_tokens).sum(); - let cost = usd(&report.requested_model, tokens) - .map_or(String::new(), |usd| format!(" · ~${usd:.4}")); + let cost = cost(crate::model::usd(&report.requested_model, tokens)); return format!( "JevGate: dry run · {} files · {planned} first-pass requests, {cached} answered by the cache · ~{tokens} new input tokens{cost}; follow-ups depend on the answers", report.files.len() @@ -173,7 +162,7 @@ pub(crate) fn headline(report: &Report) -> String { Some(gate) => format!("gate failed: {}", gate.reasons.join("; ")), None => "gate not evaluated".to_string(), }; - let cost = estimated_usd(report).map_or(String::new(), |usd| format!(" · ~${usd:.4}")); + let cost = cost(report.estimated_usd); format!( "JevGate: {} · {gate} · {} files · {} API requests · {} input tokens{cost}", report.status, diff --git a/src/requests.rs b/src/requests.rs index 8315d55..af5fc4f 100644 --- a/src/requests.rs +++ b/src/requests.rs @@ -171,7 +171,7 @@ impl Session<'_> { let batch: Vec<&Value> = pending.iter().map(|(_, r)| *r).collect(); let store = self.store; let requests_count = &mut self.requests; - let paid = (&mut self.paid_input_tokens, &mut self.paid_output_tokens); + let paid = &mut self.paid; let observed = &mut self.observed; self.evaluator.evaluate_queue( &batch, @@ -181,10 +181,14 @@ impl Session<'_> { let (i, request) = pending[index]; *requests_count += u32::from(outcome.attempted); let receipt = &mut receipts[i]; - record(store, request, outcome, receipt); - *paid.0 += receipt.metrics.input_tokens; - *paid.1 += receipt.metrics.output_tokens; - if receipt.metrics.evaluated_judgments > 0 { + let billed = record(store, request, outcome, receipt); + // An answer without usage says nothing of its tokens, so it + // stays out of the bytes-per-token calibration. + let metered = billed.as_ref().is_some_and(|b| b.input_tokens.is_some()); + if let Some(billed) = billed { + paid.bill(billed); + } + if metered && receipt.metrics.evaluated_judgments > 0 { observed.0 += serde_json::to_vec(&provider_request(request)) .map_or(0, |v| v.len() as u64); observed.1 += receipt.metrics.input_tokens; @@ -194,13 +198,60 @@ impl Session<'_> { } } -/// Record one outcome: timing, token usage, and a validated answer saved to the cache. +/// What one answered request was billed: the model that answered, and the +/// tokens its response reported. +pub(super) struct Billed { + model: String, + /// None when the response reported no usage. + input_tokens: Option, + output_tokens: u64, +} + +/// What this invocation's requests were billed, and by which models. +#[derive(Default)] +pub struct Usage { + pub input_tokens: u64, + pub output_tokens: u64, + /// Input tokens by the model that answered them. + pub models: BTreeMap, + /// Answers whose response reported no usage. + pub unmetered: u32, +} + +impl Usage { + fn bill(&mut self, billed: Billed) { + self.output_tokens += billed.output_tokens; + match billed.input_tokens { + Some(tokens) => { + self.input_tokens += tokens; + *self.models.entry(billed.model).or_default() += tokens; + } + None => self.unmetered += 1, + } + } + + /// Dollars, priced by the model that answered each request; unknown when + /// an answer reported no usage or a model has no published price. + pub fn usd(&self) -> Option { + if self.unmetered > 0 { + return None; + } + self.models + .iter() + .map(|(model, tokens)| crate::model::usd(model, *tokens)) + .sum() + } +} + +/// Record one outcome: timing, token usage, and a validated answer saved to +/// the cache. Returns what an answered request was billed, even when its +/// answer failed validation. fn record( store: &crate::storage::Store, request: &Value, outcome: crate::transport::Outcome, receipt: &mut Receipt, -) { +) -> Option { receipt.metrics.service_ms = outcome.elapsed_ms; receipt.metrics.queue_wait_ms = outcome.started_ms; receipt.metrics.evidence_bytes = if outcome.attempted { @@ -208,9 +259,17 @@ fn record( } else { 0 }; + let mut billed = None; receipt.result = outcome.result.and_then(|body| { - receipt.metrics.input_tokens += usage(&body, "input_tokens"); - receipt.metrics.output_tokens += usage(&body, "output_tokens"); + let input_tokens = response::input_tokens(&body); + let output_tokens = response::output_tokens(&body); + receipt.metrics.input_tokens += input_tokens.unwrap_or(0); + receipt.metrics.output_tokens += output_tokens; + billed = Some(Billed { + model: body["model"].as_str().unwrap_or_default().to_owned(), + input_tokens, + output_tokens, + }); response::validate(&body, request)?; let timestamp = schema::now(); store.save( @@ -229,6 +288,7 @@ fn record( receipt.metrics.failed_attempts = 1; } } + billed } pub(super) fn require_current( @@ -275,16 +335,6 @@ fn require_paths( Ok(()) } -/// A usage count above this is corrupt, not a real count, and is ignored. -const MAX_REPORTED_TOKENS: u64 = 1_000_000_000; - -fn usage(body: &Value, field: &str) -> u64 { - body["usage"][field] - .as_u64() - .filter(|n| *n <= MAX_REPORTED_TOKENS) - .unwrap_or(0) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/response.rs b/src/response.rs index d29d3b0..52fb7fd 100644 --- a/src/response.rs +++ b/src/response.rs @@ -7,7 +7,12 @@ use serde_json::{Map, Value}; const ROUNDING_PER_VALUE: f64 = 0.005; const MAX_MASS_ERROR: f64 = 0.05; const FLOAT_NOISE: f64 = 1e-9; +/// A usage count above this is corrupt, not a real count, and is ignored. +const MAX_REPORTED_TOKENS: u64 = 1_000_000_000; +/// A response whose answers match the request's questions. Its `usage` is not +/// required: a gateway need not pass TypeSafe's through, and such an answer +/// counts as unmetered rather than as free. pub fn validate(response: &Value, request: &Value) -> Result<()> { validate_model(response, request)?; let answers = response["answers"].as_object().context("Missing answers")?; @@ -27,17 +32,25 @@ pub fn validate(response: &Value, request: &Value) -> Result<()> { validate_distribution(answer, question)?; } } - for field in ["input_tokens", "output_tokens"] { - ensure!( - response["usage"][field] - .as_u64() - .is_some_and(|n| n <= 1_000_000_000), - "Missing token usage" - ); - } Ok(()) } +/// The billed input tokens a response reports; none when it reports no usage. +pub fn input_tokens(response: &Value) -> Option { + token_count(response, "input_tokens") +} + +/// The output tokens a response reports, zero when it reports none: they are free. +pub fn output_tokens(response: &Value) -> u64 { + token_count(response, "output_tokens").unwrap_or(0) +} + +fn token_count(response: &Value, field: &str) -> Option { + response["usage"][field] + .as_u64() + .filter(|n| *n <= MAX_REPORTED_TOKENS) +} + /// A well-formed model name; when a pinned version was requested, that /// version, with or without a gateway's namespace (`typesafe-ai/jev-1.13.0` /// asked, `jev-1.13.0` answered). An alias answers with whatever version it @@ -150,14 +163,20 @@ fn validate_choice(answer: &Value, probabilities: &Map) -> Result Ok(()) } +/// The cached form of a validated response: its model, the typed answers and, +/// when it reported one, its usage. pub fn cache_value(response: &Value, request: &Value) -> Value { let mut answers = serde_json::Map::new(); for (key, question) in request["questions"].as_object().unwrap() { let kind = question["type"].as_str().unwrap(); answers.insert(key.clone(), typed_fields(&response["answers"][key], kind)); } - serde_json::json!({"model":response["model"], "answers":answers, - "usage":{"input_tokens":response["usage"]["input_tokens"], "output_tokens":response["usage"]["output_tokens"]}}) + let mut value = serde_json::json!({"model": response["model"], "answers": answers}); + if let Some(input) = input_tokens(response) { + value["usage"] = + serde_json::json!({"input_tokens": input, "output_tokens": output_tokens(response)}); + } + value } /// Only the fields a typed answer defines; anything else the provider sent is dropped. @@ -219,4 +238,26 @@ mod tests { ); } } + + #[test] + fn an_answer_without_usage_is_accepted_as_unmetered_and_cached_without_it() { + let (request, mut response) = exchange("typesafe-ai/jev", "typesafe-ai/jev"); + assert_eq!(input_tokens(&response), Some(10)); + let cached = cache_value(&response, &request); + assert_eq!( + cached["usage"], + json!({"input_tokens": 10, "output_tokens": 1}) + ); + for usage in [ + Value::Null, + json!({"inputTokens": 10}), + json!({"input_tokens": -1}), + ] { + response["usage"] = usage; + assert!(validate(&response, &request).is_ok(), "{response}"); + assert_eq!(input_tokens(&response), None); + assert!(cache_value(&response, &request).get("usage").is_none()); + assert!(validate(&cache_value(&response, &request), &request).is_ok()); + } + } } diff --git a/src/schema/report.rs b/src/schema/report.rs index c63943f..f3cdc53 100644 --- a/src/schema/report.rs +++ b/src/schema/report.rs @@ -264,6 +264,18 @@ pub struct Report { pub concurrency: u32, pub paid_input_tokens: u64, pub paid_output_tokens: u64, + /// This invocation's paid input tokens by the model that answered them, + /// which for an alias is the version it pointed to. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub paid_models: BTreeMap, + /// Paid requests whose response reported no token usage; their tokens are + /// not in `paid_input_tokens`, and the cost is unknown. + #[serde(default)] + pub unmetered_requests: u32, + /// Estimated dollars for this invocation's paid requests, priced by the + /// model that answered each; null when unknown. + #[serde(default)] + pub estimated_usd: Option, #[serde(default)] pub stages: BTreeMap, pub settled: bool, diff --git a/src/tests/mod.rs b/src/tests/mod.rs index d7ff342..25e33fd 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -187,8 +187,7 @@ pub(super) fn session<'a>( store, evaluator, requests: 0, - paid_input_tokens: 0, - paid_output_tokens: 0, + paid: Default::default(), budget: token_budget::TokenBudget::default(), observed: (0, 0), } @@ -299,6 +298,51 @@ fn model_and_refresh_invalidate_cache() { assert_eq!(mock.calls, 3); } +/// Answers as a provider that names `model` and reports usage only when `metered`. +struct Answering { + model: &'static str, + metered: bool, +} +impl transport::Evaluator for Answering { + fn evaluate(&mut self, request: &Value) -> anyhow::Result { + let mut body = answer(request, 0); + body["model"] = json!(self.model); + if !self.metered { + body.as_object_mut().unwrap().remove("usage"); + } + Ok(body) + } +} + +#[test] +fn cost_is_priced_by_the_answering_model_and_unknown_without_usage() { + let project = Project::new(); + project.write("lib.rs", &function("f")); + let mut options = args(); + options.model = Some("jev-latest".into()); + let metered = Answering { + model: "jev-1.13.0", + metered: true, + }; + let report = run(&project, &options, &mut { metered }); + assert_eq!(report.paid_models, [("jev-1.13.0".to_string(), 10)].into()); + assert!((report.estimated_usd.unwrap() - 10.0 * 0.042 / 1e6).abs() < 1e-15); + assert!(output::headline(&report).ends_with("· 10 input tokens · ~$0.0000")); + options.model = Some("typesafe-ai/jev".into()); + let unmetered = Answering { + model: "typesafe-ai/jev", + metered: false, + }; + let report = run(&project, &options, &mut { unmetered }); + assert!(report.complete); + assert_eq!((report.api_requests, report.unmetered_requests), (1, 1)); + assert_eq!((report.paid_input_tokens, report.estimated_usd), (0, None)); + assert!(output::headline(&report).ends_with("· 0 input tokens · cost unknown")); + let replay = run(&project, &options, &mut Mock::default()); + assert_eq!((replay.api_requests, replay.estimated_usd), (0, Some(0.0))); + assert!(replay.files.iter().all(|file| file.cached)); +} + #[test] fn malformed_response_and_exhausted_budget_never_pass() { let project = two_files(); From d52ae575e6faf40e17e93bb456b00a341e1a6198 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:36:04 -0300 Subject: [PATCH 005/306] Move the transport tests to their own file A pure move: the queue, retry and error tests leave transport.rs (915 lines, half of them tests) for transport/tests.rs, before the provider exchange tests join them. --- src/{transport.rs => transport/mod.rs} | 417 +------------------------ src/transport/tests.rs | 414 ++++++++++++++++++++++++ 2 files changed, 415 insertions(+), 416 deletions(-) rename src/{transport.rs => transport/mod.rs} (51%) create mode 100644 src/transport/tests.rs diff --git a/src/transport.rs b/src/transport/mod.rs similarity index 51% rename from src/transport.rs rename to src/transport/mod.rs index 63d5a67..4f4129b 100644 --- a/src/transport.rs +++ b/src/transport/mod.rs @@ -497,419 +497,4 @@ pub(super) fn key_from_file(path: &Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn fast() -> ProviderAccess { - ProviderAccess { - backoff: Duration::from_millis(1), - ..Default::default() - } - } - - fn sends(access: &ProviderAccess, results: Vec>) -> (Outcome, usize) { - let request = json!({"index":0}); - let calls = std::sync::atomic::AtomicUsize::new(0); - let results = Mutex::new(results.into_iter()); - let mut last = None; - access.evaluate_queue( - &[&request], - 1, - &|_| Ok(()), - |_| { - calls.fetch_add(1, Ordering::Relaxed); - results.lock().unwrap().next().unwrap() - }, - &mut |_, outcome| last = Some(outcome), - ); - (last.unwrap(), calls.load(Ordering::Relaxed)) - } - - #[test] - fn rate_limits_retry_after_the_requested_pause_and_count_retries() { - let access = fast(); - let start = Instant::now(); - let (outcome, calls) = sends( - &access, - vec![ - Err(provider_error(429, None, Some(1)).into()), - Ok(json!({"answers":{}})), - ], - ); - assert!(outcome.result.is_ok()); - assert_eq!((calls, outcome.retries), (2, 1)); - assert!( - start.elapsed() >= Duration::from_secs(1), - "retry-after is honored" - ); - for (status, error) in [ - (529, provider_error(529, None, None)), - (502, provider_error(502, None, None)), - ] { - let (outcome, calls) = sends(&access, vec![Err(error.into()), Ok(json!({}))]); - assert_eq!((calls, outcome.retries), (2, 1), "{status}"); - } - let (outcome, calls) = sends(&access, vec![Err(Unsent.into()), Ok(json!({}))]); - assert_eq!((calls, outcome.retries), (2, 1), "connection never opened"); - assert_eq!( - retry_delay(&provider_error(429, None, Some(3600)).into()), - Some((Some(RETRY_AFTER_CAP), ATTEMPTS)) - ); - } - - #[test] - fn server_errors_retry_and_an_interrupted_request_is_sent_twice_at_most() { - for status in [500, 520, 522, 524] { - let (outcome, calls) = sends( - &fast(), - vec![ - Err(provider_error(status, None, None).into()), - Ok(json!({})), - ], - ); - assert!(outcome.result.is_ok(), "{status}"); - assert_eq!((calls, outcome.retries), (2, 1), "{status}"); - } - let (outcome, calls) = sends(&fast(), vec![Err(Interrupted.into()), Ok(json!({}))]); - assert!(outcome.result.is_ok()); - assert_eq!(calls, 2, "a timeout passes on its second send"); - let failures = (0..4).map(|_| Err(Interrupted.into())).collect(); - let (outcome, calls) = sends(&fast(), failures); - assert_eq!(calls, INTERRUPTED_ATTEMPTS as usize); - let message = outcome.result.unwrap_err().to_string(); - assert!(message.contains("timed out") && message.contains("gave up after 2 attempts")); - } - - #[test] - fn validation_transport_and_account_errors_are_sent_once() { - for error in [ - anyhow::Error::from(provider_error(422, None, None)), - provider_error(400, None, Some(1)).into(), - provider_error(401, None, None).into(), - anyhow::anyhow!("TypeSafe transport failure; request was not retried"), - ] { - let text = error.to_string(); - let (outcome, calls) = sends(&fast(), vec![Err(error), Ok(json!({}))]); - assert_eq!((calls, outcome.retries), (1, 0), "{text}"); - assert!(outcome.result.is_err()); - } - } - - #[test] - fn persistent_overload_stops_after_the_attempt_limit() { - let failures = (0..ATTEMPTS + 2) - .map(|_| Err(provider_error(503, None, None).into())) - .collect(); - let (outcome, calls) = sends(&fast(), failures); - assert_eq!(calls, ATTEMPTS as usize); - assert_eq!(outcome.retries, ATTEMPTS - 1); - let message = outcome.result.unwrap_err().to_string(); - assert!(message.contains("HTTP 503") && message.contains("gave up after 4 attempts")); - } - - #[test] - fn account_rejections_stop_pending_uploads_but_keep_in_flight_successes() { - use std::sync::{Barrier, Condvar, Mutex, atomic::AtomicUsize}; - let access = fast(); - let requests: Vec<_> = (0..24).map(|i| json!({"index":i})).collect(); - let batch: Vec<_> = requests.iter().collect(); - let first_four = Barrier::new(4); - let released = (Mutex::new(false), Condvar::new()); - let calls = AtomicUsize::new(0); - let mut outcomes = Vec::new(); - access.evaluate_queue( - &batch, - 4, - &|_| Ok(()), - |request| { - calls.fetch_add(1, Ordering::Relaxed); - let index = request["index"].as_u64().unwrap(); - if index < 4 { - first_four.wait(); - if index == 0 { - return Err(provider_error(402, None, None).into()); - } - let (released, timeout) = released - .1 - .wait_timeout_while( - released.0.lock().unwrap(), - Duration::from_secs(5), - |done| !*done, - ) - .unwrap(); - assert!(*released && !timeout.timed_out()); - } - Ok(request.clone()) - }, - &mut |index, outcome| { - if index == 0 { - *released.0.lock().unwrap() = true; - released.1.notify_all(); - } - outcomes.push((index, outcome)); - }, - ); - outcomes.sort_by_key(|(index, _)| *index); - assert_eq!(calls.load(Ordering::Relaxed), 4); - assert_eq!(outcomes.len(), 24); - assert!(outcomes[0].1.attempted && outcomes[0].1.result.is_err()); - for (_, outcome) in &outcomes[1..4] { - assert!(outcome.attempted && outcome.result.is_ok()); - } - for (_, outcome) in &outcomes[4..] { - assert!(!outcome.attempted); - assert_eq!(outcome.elapsed_ms, 0); - assert!( - outcome - .result - .as_ref() - .unwrap_err() - .to_string() - .contains("not sent after HTTP 402") - ); - } - access.evaluate_queue( - &batch, - 4, - &|_| panic!("stopped before freshness work"), - |_| panic!("stopped across later stages"), - &mut |_, outcome| assert!(!outcome.attempted), - ); - } - - #[test] - fn only_typed_account_errors_stop_siblings_and_a_new_review_can_retry() { - let requests = [json!({"index":0}), json!({"index":1})]; - let batch: Vec<_> = requests.iter().collect(); - for status in [400, 401, 402, 403, 422, 429, 503, 529] { - let mut access = fast(); - let mut attempts = 0; - access.evaluate_queue( - &batch, - 1, - &|_| Ok(()), - |request| { - if request["index"] == 0 { - Err(anyhow::Error::new(provider_error(status, None, None)) - .context("provider response")) - } else { - Ok(request.clone()) - } - }, - &mut |_, outcome| attempts += usize::from(outcome.attempted), - ); - let rejected = matches!(status, 401..=403); - assert_eq!(attempts, if rejected { 1 } else { 2 }, "{status}"); - assert_eq!(access.reset(), rejected); - access.evaluate_queue( - &batch, - 1, - &|_| Ok(()), - |request| Ok(request.clone()), - &mut |_, outcome| assert!(outcome.attempted && outcome.result.is_ok()), - ); - } - let access = fast(); - let mut attempts = 0; - access.evaluate_queue( - &batch, - 1, - &|_| Ok(()), - |_| Err(anyhow::anyhow!("source text says TypeSafe HTTP 402")), - &mut |_, outcome| attempts += usize::from(outcome.attempted), - ); - assert_eq!(attempts, 2, "arbitrary error text cannot close the queue"); - access.evaluate_queue( - &batch, - 1, - &|request| { - if request["index"] == 0 { - bail!("stale source") - } - Ok(()) - }, - |request| Ok(request.clone()), - &mut |index, outcome| { - assert_eq!(outcome.attempted, index == 1); - }, - ); - } - - #[test] - fn rejected_review_keeps_cached_judgments_and_recovers_only_unfinished_work() { - use crate::tests::{Project, answer, args, run}; - struct Provider { - access: ProviderAccess, - reject: bool, - } - impl Evaluator for Provider { - fn begin_review(&mut self) { - self.access.reset(); - } - fn evaluate(&mut self, _: &Value) -> Result { - unreachable!("queue path") - } - fn evaluate_queue( - &mut self, - requests: &[&Value], - concurrency: usize, - before: &(dyn Fn(&Value) -> Result<()> + Sync), - completed: &mut dyn FnMut(usize, Outcome), - ) { - self.access.evaluate_queue( - requests, - concurrency, - before, - |request| { - if self.reject { - Err(provider_error(402, None, None).into()) - } else { - Ok(answer(request, 0)) - } - }, - completed, - ); - } - } - let project = Project::new(); - for name in ["a", "b", "c", "d"] { - project.write(&format!("{name}.py"), &format!("def {name}(fn):\n try:\n return fn()\n except OSError:\n log(fn)\n raise\n")); - } - project.write("b.py", "def b(fn):\n try:\n return fn()\n except OSError:\n log(fn)\n raise\n\ndef second(fn):\n try:\n return fn()\n except ValueError:\n log(fn)\n raise\n"); - let mut options = args(); - options.quick = true; - options.rules = vec!["function_simplification".into()]; - options.concurrency = 1; - options.paths = vec!["a.py".into()]; - let mut provider = Provider { - access: fast(), - reject: false, - }; - let warm = run(&project, &options, &mut provider); - assert!(warm.complete); - assert_eq!(warm.api_requests, 1); - options.paths.clear(); - provider.reject = true; - let rejected = run(&project, &options, &mut provider); - assert!(!rejected.complete); - assert_eq!(rejected.api_requests, 1); - assert_eq!(rejected.files[0].status, crate::schema::Status::Clear); - assert!(rejected.files[0].cached); - assert!( - rejected.files[1..] - .iter() - .all(|f| f.status == crate::schema::Status::Error) - ); - assert_eq!(rejected.stages["functions"].failed_attempts, 1); - assert_eq!(rejected.stages["functions"].cache_hits, 1); - assert_eq!( - rejected.files[1].error.as_deref(), - Some("TypeSafe HTTP 402; request was not retried"), - "later unsent work in the same file must not hide the original provider failure" - ); - assert!( - rejected.files[2..] - .iter() - .all(|f| f.error.as_ref().unwrap().contains("not sent")) - ); - let saved = crate::storage::read_latest(&project.0).unwrap(); - assert!(!saved.complete); - assert_eq!(saved.api_requests, 1); - provider.reject = false; - let recovered = run(&project, &options, &mut provider); - assert!(recovered.complete); - assert_eq!( - recovered.api_requests, 3, - "failed and unsent requests were not cached" - ); - options.cache_only = true; - let replay = run(&project, &options, &mut provider); - assert!(replay.complete); - assert_eq!(replay.api_requests, 0); - for (expected, actual) in recovered.files.iter().zip(replay.files) { - assert_eq!(json!(expected.dimensions), json!(actual.dimensions)); - } - } - - #[test] - fn a_context_limit_error_is_named_without_echoing_private_text() { - let body = json!({"detail":{"error_type":"max_tokens_exceeded","message":"private source and credentials"}}); - assert_eq!( - provider_error(400, Some(&body.to_string()), None).to_string(), - "TypeSafe HTTP 400 (model context limit exceeded); request was not retried" - ); - } - - #[test] - fn unknown_error_details_are_not_echoed() { - for body in [ - json!({"detail":"private source"}), - json!({"detail":{"error_type":"private credentials"}}), - ] { - assert_eq!( - provider_error(400, Some(&body.to_string()), None).to_string(), - "TypeSafe HTTP 400; request was not retried" - ); - } - assert_eq!( - provider_error(503, None, None).to_string(), - "TypeSafe HTTP 503" - ); - } - - const EDGE_PAGE: &str = - "Attention Required! | Cloudflare"; - - #[test] - fn request_bodies_are_compact_without_local_metadata() { - let request = json!({"model": "m", "state": {"source": ""}, "jevgate": {}}); - let body = String::from_utf8(request_body(&request).unwrap()).unwrap(); - assert_eq!(body, r#"{"model":"m","state":{"source":""}}"#); - } - - #[test] - fn edge_firewall_blocks_are_told_apart_from_account_rejections() { - let edge = provider_error(403, Some("error code: 1010\n"), None); - assert!(edge.edge_block); - assert_eq!( - edge.to_string(), - "TypeSafe HTTP 403 (blocked by the provider's edge protection); request was not retried" - ); - assert!(provider_error(403, Some(EDGE_PAGE), None).edge_block); - assert!(!provider_error(403, Some("{\"detail\":\"forbidden\"}"), None).edge_block); - } - - #[test] - fn isolated_edge_blocks_fail_alone_and_consecutive_blocks_stop_uploads() { - let access = ProviderAccess::default(); - let blocked: Result = Err(provider_error(403, Some(EDGE_PAGE), None).into()); - access.observe(&blocked); - access.observe(&blocked); - access.observe(&Ok(json!({}))); - access.observe(&blocked); - assert!(access.check().is_ok(), "a success resets the count"); - access.observe(&blocked); - access.observe(&blocked); - assert!( - access - .check() - .unwrap_err() - .to_string() - .contains("edge protection") - ); - } - - #[test] - fn credential_parser_does_not_execute_shell() { - let project = crate::tests::Project::new(); - project.write( - ".env", - "export TYPESAFE_API_KEY='literal$(do-not-execute)'\n", - ); - assert_eq!( - key_from_file(&project.0.join(".env")).unwrap(), - "literal$(do-not-execute)" - ); - } -} +mod tests; diff --git a/src/transport/tests.rs b/src/transport/tests.rs new file mode 100644 index 0000000..40ce398 --- /dev/null +++ b/src/transport/tests.rs @@ -0,0 +1,414 @@ +use super::*; +use serde_json::json; + +fn fast() -> ProviderAccess { + ProviderAccess { + backoff: Duration::from_millis(1), + ..Default::default() + } +} + +fn sends(access: &ProviderAccess, results: Vec>) -> (Outcome, usize) { + let request = json!({"index":0}); + let calls = std::sync::atomic::AtomicUsize::new(0); + let results = Mutex::new(results.into_iter()); + let mut last = None; + access.evaluate_queue( + &[&request], + 1, + &|_| Ok(()), + |_| { + calls.fetch_add(1, Ordering::Relaxed); + results.lock().unwrap().next().unwrap() + }, + &mut |_, outcome| last = Some(outcome), + ); + (last.unwrap(), calls.load(Ordering::Relaxed)) +} + +#[test] +fn rate_limits_retry_after_the_requested_pause_and_count_retries() { + let access = fast(); + let start = Instant::now(); + let (outcome, calls) = sends( + &access, + vec![ + Err(provider_error(429, None, Some(1)).into()), + Ok(json!({"answers":{}})), + ], + ); + assert!(outcome.result.is_ok()); + assert_eq!((calls, outcome.retries), (2, 1)); + assert!( + start.elapsed() >= Duration::from_secs(1), + "retry-after is honored" + ); + for (status, error) in [ + (529, provider_error(529, None, None)), + (502, provider_error(502, None, None)), + ] { + let (outcome, calls) = sends(&access, vec![Err(error.into()), Ok(json!({}))]); + assert_eq!((calls, outcome.retries), (2, 1), "{status}"); + } + let (outcome, calls) = sends(&access, vec![Err(Unsent.into()), Ok(json!({}))]); + assert_eq!((calls, outcome.retries), (2, 1), "connection never opened"); + assert_eq!( + retry_delay(&provider_error(429, None, Some(3600)).into()), + Some((Some(RETRY_AFTER_CAP), ATTEMPTS)) + ); +} + +#[test] +fn server_errors_retry_and_an_interrupted_request_is_sent_twice_at_most() { + for status in [500, 520, 522, 524] { + let (outcome, calls) = sends( + &fast(), + vec![ + Err(provider_error(status, None, None).into()), + Ok(json!({})), + ], + ); + assert!(outcome.result.is_ok(), "{status}"); + assert_eq!((calls, outcome.retries), (2, 1), "{status}"); + } + let (outcome, calls) = sends(&fast(), vec![Err(Interrupted.into()), Ok(json!({}))]); + assert!(outcome.result.is_ok()); + assert_eq!(calls, 2, "a timeout passes on its second send"); + let failures = (0..4).map(|_| Err(Interrupted.into())).collect(); + let (outcome, calls) = sends(&fast(), failures); + assert_eq!(calls, INTERRUPTED_ATTEMPTS as usize); + let message = outcome.result.unwrap_err().to_string(); + assert!(message.contains("timed out") && message.contains("gave up after 2 attempts")); +} + +#[test] +fn validation_transport_and_account_errors_are_sent_once() { + for error in [ + anyhow::Error::from(provider_error(422, None, None)), + provider_error(400, None, Some(1)).into(), + provider_error(401, None, None).into(), + anyhow::anyhow!("TypeSafe transport failure; request was not retried"), + ] { + let text = error.to_string(); + let (outcome, calls) = sends(&fast(), vec![Err(error), Ok(json!({}))]); + assert_eq!((calls, outcome.retries), (1, 0), "{text}"); + assert!(outcome.result.is_err()); + } +} + +#[test] +fn persistent_overload_stops_after_the_attempt_limit() { + let failures = (0..ATTEMPTS + 2) + .map(|_| Err(provider_error(503, None, None).into())) + .collect(); + let (outcome, calls) = sends(&fast(), failures); + assert_eq!(calls, ATTEMPTS as usize); + assert_eq!(outcome.retries, ATTEMPTS - 1); + let message = outcome.result.unwrap_err().to_string(); + assert!(message.contains("HTTP 503") && message.contains("gave up after 4 attempts")); +} + +#[test] +fn account_rejections_stop_pending_uploads_but_keep_in_flight_successes() { + use std::sync::{Barrier, Condvar, Mutex, atomic::AtomicUsize}; + let access = fast(); + let requests: Vec<_> = (0..24).map(|i| json!({"index":i})).collect(); + let batch: Vec<_> = requests.iter().collect(); + let first_four = Barrier::new(4); + let released = (Mutex::new(false), Condvar::new()); + let calls = AtomicUsize::new(0); + let mut outcomes = Vec::new(); + access.evaluate_queue( + &batch, + 4, + &|_| Ok(()), + |request| { + calls.fetch_add(1, Ordering::Relaxed); + let index = request["index"].as_u64().unwrap(); + if index < 4 { + first_four.wait(); + if index == 0 { + return Err(provider_error(402, None, None).into()); + } + let (released, timeout) = released + .1 + .wait_timeout_while( + released.0.lock().unwrap(), + Duration::from_secs(5), + |done| !*done, + ) + .unwrap(); + assert!(*released && !timeout.timed_out()); + } + Ok(request.clone()) + }, + &mut |index, outcome| { + if index == 0 { + *released.0.lock().unwrap() = true; + released.1.notify_all(); + } + outcomes.push((index, outcome)); + }, + ); + outcomes.sort_by_key(|(index, _)| *index); + assert_eq!(calls.load(Ordering::Relaxed), 4); + assert_eq!(outcomes.len(), 24); + assert!(outcomes[0].1.attempted && outcomes[0].1.result.is_err()); + for (_, outcome) in &outcomes[1..4] { + assert!(outcome.attempted && outcome.result.is_ok()); + } + for (_, outcome) in &outcomes[4..] { + assert!(!outcome.attempted); + assert_eq!(outcome.elapsed_ms, 0); + assert!( + outcome + .result + .as_ref() + .unwrap_err() + .to_string() + .contains("not sent after HTTP 402") + ); + } + access.evaluate_queue( + &batch, + 4, + &|_| panic!("stopped before freshness work"), + |_| panic!("stopped across later stages"), + &mut |_, outcome| assert!(!outcome.attempted), + ); +} + +#[test] +fn only_typed_account_errors_stop_siblings_and_a_new_review_can_retry() { + let requests = [json!({"index":0}), json!({"index":1})]; + let batch: Vec<_> = requests.iter().collect(); + for status in [400, 401, 402, 403, 422, 429, 503, 529] { + let mut access = fast(); + let mut attempts = 0; + access.evaluate_queue( + &batch, + 1, + &|_| Ok(()), + |request| { + if request["index"] == 0 { + Err(anyhow::Error::new(provider_error(status, None, None)) + .context("provider response")) + } else { + Ok(request.clone()) + } + }, + &mut |_, outcome| attempts += usize::from(outcome.attempted), + ); + let rejected = matches!(status, 401..=403); + assert_eq!(attempts, if rejected { 1 } else { 2 }, "{status}"); + assert_eq!(access.reset(), rejected); + access.evaluate_queue( + &batch, + 1, + &|_| Ok(()), + |request| Ok(request.clone()), + &mut |_, outcome| assert!(outcome.attempted && outcome.result.is_ok()), + ); + } + let access = fast(); + let mut attempts = 0; + access.evaluate_queue( + &batch, + 1, + &|_| Ok(()), + |_| Err(anyhow::anyhow!("source text says TypeSafe HTTP 402")), + &mut |_, outcome| attempts += usize::from(outcome.attempted), + ); + assert_eq!(attempts, 2, "arbitrary error text cannot close the queue"); + access.evaluate_queue( + &batch, + 1, + &|request| { + if request["index"] == 0 { + bail!("stale source") + } + Ok(()) + }, + |request| Ok(request.clone()), + &mut |index, outcome| { + assert_eq!(outcome.attempted, index == 1); + }, + ); +} + +#[test] +fn rejected_review_keeps_cached_judgments_and_recovers_only_unfinished_work() { + use crate::tests::{Project, answer, args, run}; + struct Provider { + access: ProviderAccess, + reject: bool, + } + impl Evaluator for Provider { + fn begin_review(&mut self) { + self.access.reset(); + } + fn evaluate(&mut self, _: &Value) -> Result { + unreachable!("queue path") + } + fn evaluate_queue( + &mut self, + requests: &[&Value], + concurrency: usize, + before: &(dyn Fn(&Value) -> Result<()> + Sync), + completed: &mut dyn FnMut(usize, Outcome), + ) { + self.access.evaluate_queue( + requests, + concurrency, + before, + |request| { + if self.reject { + Err(provider_error(402, None, None).into()) + } else { + Ok(answer(request, 0)) + } + }, + completed, + ); + } + } + let project = Project::new(); + for name in ["a", "b", "c", "d"] { + project.write(&format!("{name}.py"), &format!("def {name}(fn):\n try:\n return fn()\n except OSError:\n log(fn)\n raise\n")); + } + project.write("b.py", "def b(fn):\n try:\n return fn()\n except OSError:\n log(fn)\n raise\n\ndef second(fn):\n try:\n return fn()\n except ValueError:\n log(fn)\n raise\n"); + let mut options = args(); + options.quick = true; + options.rules = vec!["function_simplification".into()]; + options.concurrency = 1; + options.paths = vec!["a.py".into()]; + let mut provider = Provider { + access: fast(), + reject: false, + }; + let warm = run(&project, &options, &mut provider); + assert!(warm.complete); + assert_eq!(warm.api_requests, 1); + options.paths.clear(); + provider.reject = true; + let rejected = run(&project, &options, &mut provider); + assert!(!rejected.complete); + assert_eq!(rejected.api_requests, 1); + assert_eq!(rejected.files[0].status, crate::schema::Status::Clear); + assert!(rejected.files[0].cached); + assert!( + rejected.files[1..] + .iter() + .all(|f| f.status == crate::schema::Status::Error) + ); + assert_eq!(rejected.stages["functions"].failed_attempts, 1); + assert_eq!(rejected.stages["functions"].cache_hits, 1); + assert_eq!( + rejected.files[1].error.as_deref(), + Some("TypeSafe HTTP 402; request was not retried"), + "later unsent work in the same file must not hide the original provider failure" + ); + assert!( + rejected.files[2..] + .iter() + .all(|f| f.error.as_ref().unwrap().contains("not sent")) + ); + let saved = crate::storage::read_latest(&project.0).unwrap(); + assert!(!saved.complete); + assert_eq!(saved.api_requests, 1); + provider.reject = false; + let recovered = run(&project, &options, &mut provider); + assert!(recovered.complete); + assert_eq!( + recovered.api_requests, 3, + "failed and unsent requests were not cached" + ); + options.cache_only = true; + let replay = run(&project, &options, &mut provider); + assert!(replay.complete); + assert_eq!(replay.api_requests, 0); + for (expected, actual) in recovered.files.iter().zip(replay.files) { + assert_eq!(json!(expected.dimensions), json!(actual.dimensions)); + } +} + +#[test] +fn a_context_limit_error_is_named_without_echoing_private_text() { + let body = json!({"detail":{"error_type":"max_tokens_exceeded","message":"private source and credentials"}}); + assert_eq!( + provider_error(400, Some(&body.to_string()), None).to_string(), + "TypeSafe HTTP 400 (model context limit exceeded); request was not retried" + ); +} + +#[test] +fn unknown_error_details_are_not_echoed() { + for body in [ + json!({"detail":"private source"}), + json!({"detail":{"error_type":"private credentials"}}), + ] { + assert_eq!( + provider_error(400, Some(&body.to_string()), None).to_string(), + "TypeSafe HTTP 400; request was not retried" + ); + } + assert_eq!( + provider_error(503, None, None).to_string(), + "TypeSafe HTTP 503" + ); +} + +const EDGE_PAGE: &str = + "Attention Required! | Cloudflare"; + +#[test] +fn request_bodies_are_compact_without_local_metadata() { + let request = json!({"model": "m", "state": {"source": ""}, "jevgate": {}}); + let body = String::from_utf8(request_body(&request).unwrap()).unwrap(); + assert_eq!(body, r#"{"model":"m","state":{"source":""}}"#); +} + +#[test] +fn edge_firewall_blocks_are_told_apart_from_account_rejections() { + let edge = provider_error(403, Some("error code: 1010\n"), None); + assert!(edge.edge_block); + assert_eq!( + edge.to_string(), + "TypeSafe HTTP 403 (blocked by the provider's edge protection); request was not retried" + ); + assert!(provider_error(403, Some(EDGE_PAGE), None).edge_block); + assert!(!provider_error(403, Some("{\"detail\":\"forbidden\"}"), None).edge_block); +} + +#[test] +fn isolated_edge_blocks_fail_alone_and_consecutive_blocks_stop_uploads() { + let access = ProviderAccess::default(); + let blocked: Result = Err(provider_error(403, Some(EDGE_PAGE), None).into()); + access.observe(&blocked); + access.observe(&blocked); + access.observe(&Ok(json!({}))); + access.observe(&blocked); + assert!(access.check().is_ok(), "a success resets the count"); + access.observe(&blocked); + access.observe(&blocked); + assert!( + access + .check() + .unwrap_err() + .to_string() + .contains("edge protection") + ); +} + +#[test] +fn credential_parser_does_not_execute_shell() { + let project = crate::tests::Project::new(); + project.write( + ".env", + "export TYPESAFE_API_KEY='literal$(do-not-execute)'\n", + ); + assert_eq!( + key_from_file(&project.0.join(".env")).unwrap(), + "literal$(do-not-execute)" + ); +} From c06d73bc251f5b65c4ed31a84648661835daef05 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:40:00 -0300 Subject: [PATCH 006/306] Record request ids, honor retry-after-ms and HTTP dates, and explain 402 and 422 TypeSafe names each request in `x-typesafe-request-id` and its SDKs honor `retry-after-ms` before `Retry-After`; JevGate kept neither and read `Retry-After` only as whole seconds. A 402 said "request was not retried" and nothing about credits; a 422 said nothing about what was invalid. An error now ends with the request id when the provider sent one (else the response's own `id`, which OpenRouter sends), and each judgment and cached answer keeps the id of the request behind it. A retry waits for `retry-after-ms`, else `Retry-After` in seconds or as an HTTP date, still capped at 30 s. A 402 says the credits are exhausted and where to add them, also in the message of every request it stopped; a 422 names each `detail[].loc` and `type`, never `msg` or `input`, which can echo source; a 404 suggests the model name; a 413 counts as beyond the model's context. Header values are kept only when they are safe to print. Messages name the provider through a `Service`, so the gateways can share them; a mock provider on 127.0.0.1 (tests/support/mock_provider.rs) tests whole exchanges: the bearer key, the uploaded body without local metadata, the request id, a 429 with `retry-after-ms`, and a 422. --- CHANGELOG.md | 1 + site/src/troubleshooting.md | 10 +- src/main.rs | 2 + src/provider.rs | 47 +++++++ src/provider_error.rs | 219 ++++++++++++++++++++++++++++----- src/response.rs | 5 +- src/response_headers.rs | 155 +++++++++++++++++++++++ src/schema/mod.rs | 3 + src/tests/mod.rs | 2 + src/transport/mod.rs | 123 +++++++++++++----- src/transport/tests.rs | 173 ++++++++++++++++++++------ src/units/answers.rs | 1 + src/units/tests/pipeline.rs | 9 +- tests/support/mock_provider.rs | 137 +++++++++++++++++++++ 14 files changed, 785 insertions(+), 102 deletions(-) create mode 100644 src/provider.rs create mode 100644 src/response_headers.rs create mode 100644 tests/support/mock_provider.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2668b4e..8f51539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver - Hardcoded values no longer runs by default: 6 of its 37 labeled reviews and considers were right on projects JevGate was never tuned on (16%), against 47 of 85 on the projects it was tuned on (55%). Without it, a default run asks 44% fewer first-pass requests (22,370 to 12,623 on 94 corpus projects) and uploads 39% fewer bytes. It stays in the `maintainability` group and in `all`; `--rule default --rule hardcoded-values` adds it to the default rules. - Models: a model name without an `x.y.z` version is an alias, whose cached answers expire after `cache_ttl_secs`, and gateways' names are accepted (`typesafe/jev-1.13`, `~typesafe/jev-latest`, `typesafe-ai/jev`). TypeSafe's docs name models `jev` and `jev-1.13`; JevGate took those for pinned versions and rejected every answer, which names the version (`jev-1.13.0`), as coming from "a different pinned model". A pinned name still accepts only its own version, with or without a gateway's namespace. The default `jev-1.13.0` asks nothing again. - Cost: a run is priced by the model that answered each request, not by the name it asked for. A `jev-latest` run showed no cost, although TypeSafe answers it with `jev-1.13.0`. A response without `usage`, which a gateway need not send, is accepted, and the run's cost is shown as unknown rather than $0; its tokens stay out of the bytes-per-token calibration. The JSON report adds `paid_models` (input tokens by the model that answered them), `unmetered_requests` and `estimated_usd` (null when unknown). +- Provider errors: an error names the provider's request id when it sent one (`x-typesafe-request-id`, else the response's own `id`), and each judgment in the report and each cached answer keeps the id of the request that answered it, to quote to the provider's support. A 402 says the credits are exhausted and where to add them. A 422 names each invalid field and its error type (`body.questions.q1.criteria missing`), never the provider's message or input, which can echo the source. A 404 suggests checking the model name, and a 413, a gateway's refusal of an oversized request, counts as beyond the model's context like TypeSafe's own `max_tokens_exceeded`. A retry waits as long as `retry-after-ms` asks, else `Retry-After` in seconds or as an HTTP date; JevGate read whole seconds only. The pause is still at most 30 seconds. ## [0.25.0] - 2026-09-27 diff --git a/site/src/troubleshooting.md b/site/src/troubleshooting.md index afbba92..3c03530 100644 --- a/site/src/troubleshooting.md +++ b/site/src/troubleshooting.md @@ -16,13 +16,21 @@ Exit code 2 means the run could not finish, or the configuration or command line **`Cannot connect to TypeSafe; request was not sent`** : A network problem before anything was sent. Rerun; cached answers are kept. +**`TypeSafe HTTP 402 (credits exhausted; add credits or turn on auto-refill at https://console.typesafe.ai)`** +: The account's prepaid credits ran out. The run stops sending requests and exits 2; the answers it received are kept in the cache. Add credits and rerun: only the unanswered units are asked. + +**`TypeSafe HTTP 422 (invalid request: body.questions.q1.criteria missing)`** +: TypeSafe refused a request as malformed. The message names each invalid field and its error type, never the text TypeSafe sends with it, which can quote your source. It is a JevGate bug: please [report it](https://github.com/Tech-Byte-Frontier/jevgate/issues/new) with the request id. + +A provider error ends with the provider's request id when it sent one (`; request id req_…`); quote it to the provider's support. The report also keeps the id of the request behind each answer (`files[].judgments[].request_id`). + **`Session API request budget exhausted; restart with an explicit larger --max-requests`** : `max_requests` or `--max-requests` capped the run. Raise it, or check fewer files with `--base` or paths; `--dry-run` estimates what a run will ask. **`Another JevGate session owns latest.json`** : Another `check` or `--watch` is running in the same repository. Stop it first. -Rate limits, overload and server errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts before the run gives up, and a timeout or dropped connection is retried once. +Rate limits, overload and server errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts before the run gives up, waiting as long as the provider asks (`retry-after-ms`, or `Retry-After` in seconds or as a date, at most 30 seconds), and a timeout or dropped connection is retried once. ## Many files are uncertain diff --git a/src/main.rs b/src/main.rs index 8696e2e..0e28b7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,9 +50,11 @@ mod options; mod output; mod packages; mod policy; +mod provider; mod provider_error; mod requests; mod response; +mod response_headers; mod revision; mod sarif; mod schema; diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..1410309 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,47 @@ +//! The service that answers Jev's questions, and what JevGate needs to know +//! to talk to it and to explain its failures. + +/// One provider of TypeSafe's API. +#[derive(Debug)] +pub struct Service { + /// The name people read in messages. + pub label: &'static str, + /// The API root; requests go to `/v1/systemone`. + pub api_root: &'static str, + /// What to do when a request is refused for want of credits (HTTP 402). + pub credits: &'static str, +} + +/// TypeSafe itself. Its API documents no 402; its terms bill prepaid credits +/// that can refill automatically (MCA §8.2), managed in the console. +pub const TYPESAFE: Service = Service { + label: "TypeSafe", + api_root: "https://api.typesafe.ai", + credits: "add credits or turn on auto-refill at https://console.typesafe.ai", +}; + +/// Where a check sends its requests, and the provider that answers there. +#[derive(Debug)] +pub struct Endpoint { + pub service: &'static Service, + root: String, +} + +impl Endpoint { + pub fn new(service: &'static Service) -> Self { + Self::at(service, service.api_root) + } + + /// An endpoint at another API root, such as a test server. + pub fn at(service: &'static Service, root: &str) -> Self { + Self { + service, + root: root.trim_end_matches('/').to_owned(), + } + } + + /// The URL questions are posted to. + pub fn systemone(&self) -> String { + format!("{}/v1/systemone", self.root) + } +} diff --git a/src/provider_error.rs b/src/provider_error.rs index ebb7d17..f7ddc18 100644 --- a/src/provider_error.rs +++ b/src/provider_error.rs @@ -1,28 +1,39 @@ //! What a failed provider response means: an unsent request, a context -//! limit, an edge-firewall block, or another HTTP status. Only verified -//! machine codes are recognized; provider text is never echoed. +//! limit, an edge-firewall block, exhausted credits, an invalid request, or +//! another HTTP status. Only verified machine codes are recognized; provider +//! text is never echoed. +use crate::provider::Service; use serde_json::Value; +use std::time::Duration; /// The connection failed before any request bytes were sent. #[derive(Debug)] -pub(crate) struct Unsent; +pub(crate) struct Unsent(pub &'static Service); impl std::error::Error for Unsent {} impl std::fmt::Display for Unsent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Cannot connect to TypeSafe; request was not sent") + write!( + f, + "Cannot connect to {}; request was not sent", + self.0.label + ) } } /// The request was sent but no answer arrived: it timed out or the connection /// dropped. The provider may have run it, so it is retried only once. #[derive(Debug)] -pub(crate) struct Interrupted; +pub(crate) struct Interrupted(pub &'static Service); impl std::error::Error for Interrupted {} impl std::fmt::Display for Interrupted { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "TypeSafe request timed out or its connection dropped") + write!( + f, + "{} request timed out or its connection dropped", + self.0.label + ) } } @@ -33,50 +44,198 @@ pub(crate) fn retryable(status: u16) -> bool { matches!(status, 408 | 429 | 500 | 502 | 503 | 504 | 520..=524 | 529) } +/// A failed response as it arrived: its status, body and headers. +#[derive(Default)] +pub(crate) struct Failure<'a> { + pub status: u16, + pub body: Option<&'a str>, + pub retry_after: Option, + pub request_id: Option, +} + #[derive(Debug)] pub(crate) struct ProviderError { + pub service: &'static Service, pub status: u16, pub context_limit: bool, /// A Cloudflare `error code: 10xx` page: the edge refused the client. pub edge_block: bool, - pub retry_after: Option, + pub retry_after: Option, + pub request_id: Option, + /// Where a 422 found the request invalid: each field's path and error type. + pub invalid: Vec, +} + +impl ProviderError { + /// What the status means, when JevGate knows: the bracketed part of the message. + fn meaning(&self) -> Option { + if self.context_limit { + return Some("model context limit exceeded".into()); + } + if self.edge_block { + return Some("blocked by the provider's edge protection".into()); + } + match self.status { + 402 => Some(format!("credits exhausted; {}", self.service.credits)), + 404 => Some("not found; check the model name".into()), + 422 if !self.invalid.is_empty() => { + Some(format!("invalid request: {}", self.invalid.join(", "))) + } + _ => None, + } + } } impl std::error::Error for ProviderError {} impl std::fmt::Display for ProviderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let detail = if self.context_limit { - " (model context limit exceeded)" - } else if self.edge_block { - " (blocked by the provider's edge protection)" - } else { - "" - }; - let retried = if retryable(self.status) { - "" - } else { - "; request was not retried" - }; - write!(f, "TypeSafe HTTP {}{detail}{retried}", self.status) + write!(f, "{} HTTP {}", self.service.label, self.status)?; + if let Some(meaning) = self.meaning() { + write!(f, " ({meaning})")?; + } + if !retryable(self.status) { + write!(f, "; request was not retried")?; + } + if let Some(id) = &self.request_id { + write!(f, "; request id {id}")?; + } + Ok(()) } } -pub(crate) fn provider_error( - status: u16, - body: Option<&str>, - retry_after: Option, -) -> ProviderError { +pub(crate) fn provider_error(service: &'static Service, failure: Failure<'_>) -> ProviderError { // Recognize only verified machine codes; do not echo arbitrary provider text. - let json = body.and_then(|text| serde_json::from_str::(text).ok()); + let status = failure.status; + let json = failure + .body + .and_then(|text| serde_json::from_str::(text).ok()); ProviderError { + service, status, - context_limit: status == 400 - && json.is_some_and(|body| body["detail"]["error_type"] == "max_tokens_exceeded"), + // A gateway refuses an oversized request with 413 before the model sees it. + context_limit: status == 413 + || (status == 400 + && json + .as_ref() + .is_some_and(|body| body["detail"]["error_type"] == "max_tokens_exceeded")), edge_block: status == 403 - && body.is_some_and(|text| { + && failure.body.is_some_and(|text| { text.trim_start().starts_with("error code: 10") || text.contains("Attention Required! | Cloudflare") }), - retry_after, + retry_after: failure.retry_after, + request_id: failure.request_id, + invalid: if status == 422 { + invalid_fields(json.as_ref()) + } else { + Vec::new() + }, + } +} + +/// Validation failures a 422 names, at most this many. +const MAX_INVALID: usize = 3; +/// A path segment or type longer than this is not a field name. +const MAX_FIELD_BYTES: usize = 64; + +/// Each `detail[]` entry of a 422 as its `loc` joined by dots and its `type`: +/// `body.questions.q1.criteria missing`. `msg` and `input` are never read, +/// since they can echo the request's source. +fn invalid_fields(body: Option<&Value>) -> Vec { + let Some(details) = body.and_then(|body| body["detail"].as_array()) else { + return Vec::new(); + }; + details + .iter() + .take(MAX_INVALID) + .map(|detail| { + let location: Vec = detail["loc"] + .as_array() + .into_iter() + .flatten() + .map(|part| match part { + Value::Number(index) => index.to_string(), + other => field_name(other.as_str()), + }) + .collect(); + format!( + "{} {}", + location.join("."), + field_name(detail["type"].as_str()) + ) + }) + .collect() +} + +/// A field name or error type fit to print, else `?`. +fn field_name(text: Option<&str>) -> String { + text.filter(|text| { + !text.is_empty() + && text.len() <= MAX_FIELD_BYTES + && text + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)) + }) + .unwrap_or("?") + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::TYPESAFE; + use serde_json::json; + + fn message(status: u16, body: Option<&str>, request_id: Option<&str>) -> String { + let failure = Failure { + status, + body, + request_id: request_id.map(Into::into), + ..Default::default() + }; + provider_error(&TYPESAFE, failure).to_string() + } + + #[test] + fn a_422_names_the_invalid_fields_but_never_their_message_or_input() { + let body = json!({"detail": [ + {"loc": ["body", "questions", "simplify_0", "criteria"], "msg": "private source", + "type": "missing", "input": {"source": "private source"}}, + {"loc": ["body", "state", 3], "msg": "private", "type": "string_too_long"}, + {"loc": ["body", "private source text"], "msg": "private", "type": "x"}, + {"loc": ["body", "model"], "msg": "private", "type": "fourth"}, + ]}) + .to_string(); + let text = message(422, Some(&body), Some("req_7")); + assert_eq!( + text, + "TypeSafe HTTP 422 (invalid request: body.questions.simplify_0.criteria missing, body.state.3 string_too_long, body.? x); request was not retried; request id req_7" + ); + assert!(!text.contains("private")); + assert_eq!( + message(422, Some("{\"detail\":\"private\"}"), None), + "TypeSafe HTTP 422; request was not retried" + ); + } + + #[test] + fn credits_not_found_and_oversized_requests_say_what_to_do() { + assert_eq!( + message(402, Some("{\"error\":\"private\"}"), None), + "TypeSafe HTTP 402 (credits exhausted; add credits or turn on auto-refill at https://console.typesafe.ai); request was not retried" + ); + assert!(message(404, None, None).contains("(not found; check the model name)")); + let oversized = provider_error( + &TYPESAFE, + Failure { + status: 413, + ..Default::default() + }, + ); + assert!(oversized.context_limit); + assert_eq!( + message(503, None, Some("abc")), + "TypeSafe HTTP 503; request id abc" + ); } } diff --git a/src/response.rs b/src/response.rs index 52fb7fd..0796115 100644 --- a/src/response.rs +++ b/src/response.rs @@ -164,7 +164,7 @@ fn validate_choice(answer: &Value, probabilities: &Map) -> Result } /// The cached form of a validated response: its model, the typed answers and, -/// when it reported one, its usage. +/// when it reported them, its usage and the provider's request id. pub fn cache_value(response: &Value, request: &Value) -> Value { let mut answers = serde_json::Map::new(); for (key, question) in request["questions"].as_object().unwrap() { @@ -176,6 +176,9 @@ pub fn cache_value(response: &Value, request: &Value) -> Value { value["usage"] = serde_json::json!({"input_tokens": input, "output_tokens": output_tokens(response)}); } + if let Some(id) = crate::response_headers::request_id(response["request_id"].as_str()) { + value["request_id"] = Value::String(id); + } value } diff --git a/src/response_headers.rs b/src/response_headers.rs new file mode 100644 index 0000000..7a49841 --- /dev/null +++ b/src/response_headers.rs @@ -0,0 +1,155 @@ +//! What a provider's response headers say: the id support needs to find a +//! request, and how long to wait before sending it again. +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// The header TypeSafe names each request by. +pub const REQUEST_ID: &str = "x-typesafe-request-id"; +/// The longest request id kept; a longer or stranger value is not an id. +const MAX_REQUEST_ID_BYTES: usize = 128; + +/// A request id fit to print: letters, digits and `._:-`. Anything else is +/// dropped, so a header cannot carry text into messages or logs. +pub fn request_id(value: Option<&str>) -> Option { + let value = value?.trim(); + (!value.is_empty() + && value.len() <= MAX_REQUEST_ID_BYTES + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._:-".contains(&c))) + .then(|| value.to_owned()) +} + +/// How long the provider asks to wait before a retry: `retry-after-ms`, which +/// TypeSafe's SDKs honor first, else `Retry-After` in seconds or as an HTTP +/// date. A date in the past asks for no wait. +pub fn retry_after(ms: Option<&str>, value: Option<&str>, now: SystemTime) -> Option { + if let Some(wait) = ms + .and_then(|ms| ms.trim().parse::().ok()) + .and_then(|ms| Duration::try_from_secs_f64(ms / 1000.0).ok()) + { + return Some(wait); + } + let value = value?.trim(); + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + http_date(value).map(|date| date.duration_since(now).unwrap_or_default()) +} + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; +const SECONDS_PER_DAY: u64 = 86_400; +/// Days from 0000-03-01 to 1970-01-01 in the proleptic Gregorian calendar. +const EPOCH_DAYS: u64 = 719_468; + +/// An HTTP date in the form HTTP requires senders to use, as in +/// `Sun, 06 Nov 1994 08:49:37 GMT`. +fn http_date(text: &str) -> Option { + let parts: Vec<&str> = text.split_ascii_whitespace().collect(); + if parts.len() != 6 || parts[5] != "GMT" { + return None; + } + let day: u64 = parts[1].parse().ok()?; + let month = MONTHS.iter().position(|m| *m == parts[2])? as u64 + 1; + let year: u64 = parts[3].parse().ok().filter(|year| *year >= 1970)?; + let clock: Vec = parts[4] + .split(':') + .map(|part| part.parse().ok()) + .collect::>()?; + let [hour, minute, second] = clock[..] else { + return None; + }; + if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 { + return None; + } + let days = days_since_epoch(year, month, day); + Some( + UNIX_EPOCH + + Duration::from_secs(days * SECONDS_PER_DAY + hour * 3600 + minute * 60 + second), + ) +} + +/// Days from 1970-01-01 to a date from 1970 on: Howard Hinnant's +/// `days_from_civil`, counting years from March so leap days fall last. +fn days_since_epoch(year: u64, month: u64, day: u64) -> u64 { + let year = if month <= 2 { year - 1 } else { year }; + let (era, year_of_era) = (year / 400, year % 400); + let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - EPOCH_DAYS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_request_id_is_kept_only_when_it_is_safe_to_print() { + assert_eq!( + request_id(Some(" req_01J9-abc.1:2 ")).as_deref(), + Some("req_01J9-abc.1:2") + ); + for value in [ + "", + "id with space", + "id\r\nX-Injected: 1", + "\u{1b}[31m", + &"a".repeat(129), + ] { + assert_eq!(request_id(Some(value)), None, "{value:?}"); + } + assert_eq!(request_id(None), None); + } + + #[test] + fn retry_after_ms_wins_then_seconds_then_an_http_date() { + let now = UNIX_EPOCH + Duration::from_secs(784_111_777); + let wait = |ms, value| retry_after(ms, value, now); + assert_eq!( + wait(Some("1500"), Some("9")), + Some(Duration::from_millis(1500)) + ); + assert_eq!( + wait(Some("12.5"), None), + Some(Duration::from_micros(12_500)) + ); + assert_eq!(wait(Some("-1"), Some("9")), Some(Duration::from_secs(9))); + assert_eq!( + wait(Some("soon"), Some(" 2 ")), + Some(Duration::from_secs(2)) + ); + // 784111777 is Sun, 06 Nov 1994 08:49:37 GMT. + assert_eq!( + wait(None, Some("Sun, 06 Nov 1994 08:50:07 GMT")), + Some(Duration::from_secs(30)) + ); + assert_eq!( + wait(None, Some("Sun, 06 Nov 1994 08:49:00 GMT")), + Some(Duration::ZERO) + ); + for value in [ + "Sunday, 06-Nov-94 08:49:37 GMT", + "Sun, 06 Nov 1994 08:49:37 UTC", + "Sun, 32 Nov 1994 08:49:37 GMT", + "tomorrow", + ] { + assert_eq!(wait(None, Some(value)), None, "{value}"); + } + assert_eq!(wait(None, None), None); + } + + #[test] + fn http_dates_count_leap_days() { + let seconds = |text| { + http_date(text) + .unwrap() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + }; + assert_eq!(seconds("Thu, 01 Jan 1970 00:00:00 GMT"), 0); + assert_eq!(seconds("Tue, 29 Feb 2000 00:00:00 GMT"), 951_782_400); + assert_eq!(seconds("Mon, 28 Sep 2026 12:00:00 GMT"), 1_790_596_800); + } +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index a2e76cd..e618622 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -70,6 +70,9 @@ pub struct Judgment { pub version: String, pub pass: Pass, pub answer: Answer, + /// The provider's id for the request that answered, to quote to its support. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, } pub fn now() -> u64 { diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 25e33fd..67be1df 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -7,6 +7,8 @@ use clap::Parser; use serde_json::{Value, json}; use std::path::PathBuf; mod gating; +#[path = "../../tests/support/mock_provider.rs"] +pub(super) mod mock_provider; mod scope; #[path = "../../tests/support/temp_dir.rs"] mod temp_dir; diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 4f4129b..a909644 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,4 +1,8 @@ -use crate::provider_error::{Interrupted, ProviderError, Unsent, provider_error, retryable}; +use crate::provider::{Endpoint, Service, TYPESAFE}; +use crate::provider_error::{ + Failure, Interrupted, ProviderError, Unsent, provider_error, retryable, +}; +use crate::response_headers::{REQUEST_ID, request_id, retry_after}; use anyhow::{Result, bail}; use serde_json::Value; use std::{ @@ -17,6 +21,8 @@ const ATTEMPTS: u32 = 4; const INTERRUPTED_ATTEMPTS: u32 = 2; /// Longest provider-requested pause that is honored before a retry. const RETRY_AFTER_CAP: Duration = Duration::from_secs(30); +/// How long one attempt may take, from connecting to reading the answer. +const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(60); pub trait Evaluator { /// A new snapshot may retry after account access has been restored. @@ -118,21 +124,30 @@ pub(crate) fn work_queue( pub struct Client { agent: ureq::Agent, + endpoint: Endpoint, key_file: std::path::PathBuf, key: Option, explicit_file: bool, access: ProviderAccess, } +/// An HTTP client that gives up on an attempt after `timeout`, follows no +/// redirect (the key must not travel elsewhere) and returns error statuses as +/// responses, so their headers and body can be read. +fn agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .max_redirects(0) + .http_status_as_error(false) + .build() + .into() +} + impl Client { pub fn new(key_file: &Path, explicit_file: bool) -> Self { Self { - agent: ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(60))) - .max_redirects(0) - .http_status_as_error(false) - .build() - .into(), + agent: agent(ATTEMPT_TIMEOUT), + endpoint: Endpoint::new(&TYPESAFE), key_file: key_file.into(), key: None, explicit_file, @@ -162,7 +177,9 @@ impl Evaluator for Client { fn evaluate(&mut self, request: &Value) -> Result { self.access.check()?; let agent = self.agent.clone(); - let result = send(&agent, self.credential()?, request); + self.credential()?; + let key = self.key.as_ref().unwrap().key.expose(); + let result = send(&agent, &self.endpoint, key, request); self.access.observe(&result); result } @@ -194,11 +211,12 @@ impl Evaluator for Client { } } let key = self.key.as_ref().unwrap().key.expose(); + let endpoint = &self.endpoint; self.access.evaluate_queue( requests, concurrency, before, - |request| send(&agent, key, request), + |request| send(&agent, endpoint, key, request), completed, ); } @@ -229,6 +247,8 @@ struct ProviderAccess { edge_blocks: AtomicU16, cooldown: Mutex>, backoff: Duration, + /// The provider the requests go to, named in the messages. + service: &'static Service, } impl Default for ProviderAccess { @@ -239,6 +259,7 @@ impl Default for ProviderAccess { edge_blocks: AtomicU16::new(0), cooldown: Mutex::new(None), backoff: FIRST_BACKOFF, + service: &TYPESAFE, } } } @@ -253,14 +274,21 @@ impl ProviderAccess { fn check(&self) -> Result<()> { let status = self.rejected.load(Ordering::Acquire); + let provider = self.service.label; if status != 0 && self.edge.load(Ordering::Acquire) { bail!( - "TypeSafe request not sent after HTTP {status} from the provider's edge protection; wait before rerunning, and contact TypeSafe if it persists" + "{provider} request not sent after HTTP {status} from the provider's edge protection; wait before rerunning, and contact {provider} if it persists" + ); + } + if status == 402 { + bail!( + "{provider} request not sent after HTTP 402 (credits exhausted); {}, then rerun the review", + self.service.credits ); } if status != 0 { bail!( - "TypeSafe request not sent after HTTP {status}; restore account access and rerun the review" + "{provider} request not sent after HTTP {status}; restore account access and rerun the review" ); } Ok(()) @@ -393,12 +421,8 @@ impl ProviderAccess { /// retried. fn retry_delay(error: &anyhow::Error) -> Option<(Option, u32)> { if let Some(error) = error.downcast_ref::() { - return retryable(error.status).then(|| { - let pause = error - .retry_after - .map(|s| Duration::from_secs(s).min(RETRY_AFTER_CAP)); - (pause, ATTEMPTS) - }); + return retryable(error.status) + .then(|| (error.retry_after.map(|p| p.min(RETRY_AFTER_CAP)), ATTEMPTS)); } if error.downcast_ref::().is_some() { return Some((None, INTERRUPTED_ATTEMPTS)); @@ -436,10 +460,14 @@ fn request_body(request: &Value) -> Result> { )?) } -fn send(agent: &ureq::Agent, key: &str, request: &Value) -> Result { +/// Send one request and return the provider's answer, with the provider's +/// request id under `request_id`: TypeSafe's `x-typesafe-request-id` header, +/// else the response's own `id`, which OpenRouter sends. +fn send(agent: &ureq::Agent, endpoint: &Endpoint, key: &str, request: &Value) -> Result { + let service = endpoint.service; let body = request_body(request)?; let response = agent - .post("https://api.typesafe.ai/v1/systemone") + .post(endpoint.systemone()) .header("Authorization", format!("Bearer {key}")) .header( "User-Agent", @@ -454,39 +482,68 @@ fn send(agent: &ureq::Agent, key: &str, request: &Value) -> Result { let mut response = match response { Ok(response) => response, Err(ureq::Error::StatusCode(status)) => { - return Err(provider_error(status, None, None).into()); + let failure = Failure { + status, + ..Default::default() + }; + return Err(provider_error(service, failure).into()); } Err(ureq::Error::HostNotFound | ureq::Error::ConnectionFailed) => { - return Err(Unsent.into()); + return Err(Unsent(service).into()); } - Err(ureq::Error::Timeout(_) | ureq::Error::Io(_)) => return Err(Interrupted.into()), - Err(_) => bail!("TypeSafe transport failure; request was not retried"), + Err(ureq::Error::Timeout(_) | ureq::Error::Io(_)) => { + return Err(Interrupted(service).into()); + } + Err(_) => bail!( + "{} transport failure; request was not retried", + service.label + ), }; - if !response.status().is_success() { - let status = response.status().as_u16(); - let retry_after = response + let header = |name: &str| { + response .headers() - .get("retry-after") + .get(name) .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()); + .map(str::to_owned) + }; + let id = request_id(header(REQUEST_ID).as_deref()); + if !response.status().is_success() { + let wait = retry_after( + header("retry-after-ms").as_deref(), + header("retry-after").as_deref(), + std::time::SystemTime::now(), + ); + let status = response.status().as_u16(); let body = response .body_mut() .with_config() .limit(65_536) .read_to_string() .ok(); - return Err(provider_error(status, body.as_deref(), retry_after).into()); + let failure = Failure { + status, + body: body.as_deref(), + retry_after: wait, + request_id: id, + }; + return Err(provider_error(service, failure).into()); } // Error bodies and headers may echo credentials or source; never render them. - response + let mut answer: Value = response .body_mut() .with_config() .limit(1_048_576) .read_json() .map_err(|error| match error { - ureq::Error::Timeout(_) | ureq::Error::Io(_) => Interrupted.into(), - _ => anyhow::anyhow!("TypeSafe returned invalid or oversized JSON"), - }) + ureq::Error::Timeout(_) | ureq::Error::Io(_) => Interrupted(service).into(), + _ => anyhow::anyhow!("{} returned invalid or oversized JSON", service.label), + })?; + if let Some(id) = id.or_else(|| request_id(answer["id"].as_str())) + && let Some(fields) = answer.as_object_mut() + { + fields.insert("request_id".into(), Value::String(id)); + } + Ok(answer) } #[cfg(test)] diff --git a/src/transport/tests.rs b/src/transport/tests.rs index 40ce398..61e6767 100644 --- a/src/transport/tests.rs +++ b/src/transport/tests.rs @@ -1,6 +1,20 @@ +//! The request queue, retries and access failures, and whole exchanges with a +//! mock provider over HTTP. use super::*; +use crate::tests::mock_provider::{MockProvider, Received, Reply}; use serde_json::json; +/// A TypeSafe failure with `status`, `body` and a pause in seconds. +fn failed(status: u16, body: Option<&str>, pause: Option) -> ProviderError { + let failure = Failure { + status, + body, + retry_after: pause.map(Duration::from_secs), + request_id: None, + }; + provider_error(&TYPESAFE, failure) +} + fn fast() -> ProviderAccess { ProviderAccess { backoff: Duration::from_millis(1), @@ -33,7 +47,7 @@ fn rate_limits_retry_after_the_requested_pause_and_count_retries() { let (outcome, calls) = sends( &access, vec![ - Err(provider_error(429, None, Some(1)).into()), + Err(failed(429, None, Some(1)).into()), Ok(json!({"answers":{}})), ], ); @@ -44,16 +58,16 @@ fn rate_limits_retry_after_the_requested_pause_and_count_retries() { "retry-after is honored" ); for (status, error) in [ - (529, provider_error(529, None, None)), - (502, provider_error(502, None, None)), + (529, failed(529, None, None)), + (502, failed(502, None, None)), ] { let (outcome, calls) = sends(&access, vec![Err(error.into()), Ok(json!({}))]); assert_eq!((calls, outcome.retries), (2, 1), "{status}"); } - let (outcome, calls) = sends(&access, vec![Err(Unsent.into()), Ok(json!({}))]); + let (outcome, calls) = sends(&access, vec![Err(Unsent(&TYPESAFE).into()), Ok(json!({}))]); assert_eq!((calls, outcome.retries), (2, 1), "connection never opened"); assert_eq!( - retry_delay(&provider_error(429, None, Some(3600)).into()), + retry_delay(&failed(429, None, Some(3600)).into()), Some((Some(RETRY_AFTER_CAP), ATTEMPTS)) ); } @@ -63,18 +77,18 @@ fn server_errors_retry_and_an_interrupted_request_is_sent_twice_at_most() { for status in [500, 520, 522, 524] { let (outcome, calls) = sends( &fast(), - vec![ - Err(provider_error(status, None, None).into()), - Ok(json!({})), - ], + vec![Err(failed(status, None, None).into()), Ok(json!({}))], ); assert!(outcome.result.is_ok(), "{status}"); assert_eq!((calls, outcome.retries), (2, 1), "{status}"); } - let (outcome, calls) = sends(&fast(), vec![Err(Interrupted.into()), Ok(json!({}))]); + let (outcome, calls) = sends( + &fast(), + vec![Err(Interrupted(&TYPESAFE).into()), Ok(json!({}))], + ); assert!(outcome.result.is_ok()); assert_eq!(calls, 2, "a timeout passes on its second send"); - let failures = (0..4).map(|_| Err(Interrupted.into())).collect(); + let failures = (0..4).map(|_| Err(Interrupted(&TYPESAFE).into())).collect(); let (outcome, calls) = sends(&fast(), failures); assert_eq!(calls, INTERRUPTED_ATTEMPTS as usize); let message = outcome.result.unwrap_err().to_string(); @@ -84,9 +98,9 @@ fn server_errors_retry_and_an_interrupted_request_is_sent_twice_at_most() { #[test] fn validation_transport_and_account_errors_are_sent_once() { for error in [ - anyhow::Error::from(provider_error(422, None, None)), - provider_error(400, None, Some(1)).into(), - provider_error(401, None, None).into(), + anyhow::Error::from(failed(422, None, None)), + failed(400, None, Some(1)).into(), + failed(401, None, None).into(), anyhow::anyhow!("TypeSafe transport failure; request was not retried"), ] { let text = error.to_string(); @@ -99,7 +113,7 @@ fn validation_transport_and_account_errors_are_sent_once() { #[test] fn persistent_overload_stops_after_the_attempt_limit() { let failures = (0..ATTEMPTS + 2) - .map(|_| Err(provider_error(503, None, None).into())) + .map(|_| Err(failed(503, None, None).into())) .collect(); let (outcome, calls) = sends(&fast(), failures); assert_eq!(calls, ATTEMPTS as usize); @@ -128,7 +142,7 @@ fn account_rejections_stop_pending_uploads_but_keep_in_flight_successes() { if index < 4 { first_four.wait(); if index == 0 { - return Err(provider_error(402, None, None).into()); + return Err(failed(402, None, None).into()); } let (released, timeout) = released .1 @@ -191,8 +205,7 @@ fn only_typed_account_errors_stop_siblings_and_a_new_review_can_retry() { &|_| Ok(()), |request| { if request["index"] == 0 { - Err(anyhow::Error::new(provider_error(status, None, None)) - .context("provider response")) + Err(anyhow::Error::new(failed(status, None, None)).context("provider response")) } else { Ok(request.clone()) } @@ -263,7 +276,7 @@ fn rejected_review_keeps_cached_judgments_and_recovers_only_unfinished_work() { before, |request| { if self.reject { - Err(provider_error(402, None, None).into()) + Err(failed(402, None, None).into()) } else { Ok(answer(request, 0)) } @@ -305,14 +318,17 @@ fn rejected_review_keeps_cached_judgments_and_recovers_only_unfinished_work() { assert_eq!(rejected.stages["functions"].cache_hits, 1); assert_eq!( rejected.files[1].error.as_deref(), - Some("TypeSafe HTTP 402; request was not retried"), + Some( + "TypeSafe HTTP 402 (credits exhausted; add credits or turn on auto-refill at https://console.typesafe.ai); request was not retried" + ), "later unsent work in the same file must not hide the original provider failure" ); - assert!( - rejected.files[2..] - .iter() - .all(|f| f.error.as_ref().unwrap().contains("not sent")) - ); + assert!(rejected.files[2..].iter().all(|f| { + f.error + .as_ref() + .unwrap() + .contains("not sent after HTTP 402 (credits exhausted); add credits") + })); let saved = crate::storage::read_latest(&project.0).unwrap(); assert!(!saved.complete); assert_eq!(saved.api_requests, 1); @@ -336,7 +352,7 @@ fn rejected_review_keeps_cached_judgments_and_recovers_only_unfinished_work() { fn a_context_limit_error_is_named_without_echoing_private_text() { let body = json!({"detail":{"error_type":"max_tokens_exceeded","message":"private source and credentials"}}); assert_eq!( - provider_error(400, Some(&body.to_string()), None).to_string(), + failed(400, Some(&body.to_string()), None).to_string(), "TypeSafe HTTP 400 (model context limit exceeded); request was not retried" ); } @@ -348,14 +364,11 @@ fn unknown_error_details_are_not_echoed() { json!({"detail":{"error_type":"private credentials"}}), ] { assert_eq!( - provider_error(400, Some(&body.to_string()), None).to_string(), + failed(400, Some(&body.to_string()), None).to_string(), "TypeSafe HTTP 400; request was not retried" ); } - assert_eq!( - provider_error(503, None, None).to_string(), - "TypeSafe HTTP 503" - ); + assert_eq!(failed(503, None, None).to_string(), "TypeSafe HTTP 503"); } const EDGE_PAGE: &str = @@ -370,20 +383,20 @@ fn request_bodies_are_compact_without_local_metadata() { #[test] fn edge_firewall_blocks_are_told_apart_from_account_rejections() { - let edge = provider_error(403, Some("error code: 1010\n"), None); + let edge = failed(403, Some("error code: 1010\n"), None); assert!(edge.edge_block); assert_eq!( edge.to_string(), "TypeSafe HTTP 403 (blocked by the provider's edge protection); request was not retried" ); - assert!(provider_error(403, Some(EDGE_PAGE), None).edge_block); - assert!(!provider_error(403, Some("{\"detail\":\"forbidden\"}"), None).edge_block); + assert!(failed(403, Some(EDGE_PAGE), None).edge_block); + assert!(!failed(403, Some("{\"detail\":\"forbidden\"}"), None).edge_block); } #[test] fn isolated_edge_blocks_fail_alone_and_consecutive_blocks_stop_uploads() { let access = ProviderAccess::default(); - let blocked: Result = Err(provider_error(403, Some(EDGE_PAGE), None).into()); + let blocked: Result = Err(failed(403, Some(EDGE_PAGE), None).into()); access.observe(&blocked); access.observe(&blocked); access.observe(&Ok(json!({}))); @@ -412,3 +425,93 @@ fn credential_parser_does_not_execute_shell() { "literal$(do-not-execute)" ); } + +/// A mock provider answering with `respond`, and a TypeSafe endpoint at it. +fn mock(respond: impl Fn(&Received) -> Reply + Send + Sync + 'static) -> (MockProvider, Endpoint) { + let provider = MockProvider::start(respond); + let endpoint = Endpoint::at(&TYPESAFE, &provider.url); + (provider, endpoint) +} + +/// A one-question request, with local metadata that must not be uploaded. +fn question() -> Value { + json!({"model": "jev-1.13.0", "state": "x", "jevgate": {"stage": "functions"}, + "questions": {"q": {"type": "noul", "instructions": "?"}}}) +} + +/// A valid answer to the request the provider received. +fn answered(received: &Received) -> Reply { + Reply::json(200, &crate::tests::answer(&received.json(), 0)) +} + +#[test] +fn an_exchange_sends_the_bearer_key_and_keeps_the_request_id() { + let (provider, endpoint) = + mock(|received| answered(received).header(REQUEST_ID, "req_01J9-abc")); + let answer = send(&agent(ATTEMPT_TIMEOUT), &endpoint, "test-key", &question()).unwrap(); + assert_eq!(answer["request_id"], "req_01J9-abc"); + assert!(crate::response::validate(&answer, &question()).is_ok()); + let received = &provider.received()[0]; + assert_eq!( + (received.method.as_str(), received.path.as_str()), + ("POST", "/v1/systemone") + ); + assert_eq!(received.header("Authorization"), Some("Bearer test-key")); + assert!(received.json().get("jevgate").is_none()); + let (_, openrouter) = mock(|received| { + let mut body = crate::tests::answer(&received.json(), 0); + body["id"] = json!("gen-dec-1789738314-X5e5"); + Reply::json(200, &body) + }); + let answer = send(&agent(ATTEMPT_TIMEOUT), &openrouter, "k", &question()).unwrap(); + assert_eq!( + answer["request_id"], "gen-dec-1789738314-X5e5", + "a response's own id stands in" + ); +} + +#[test] +fn a_rate_limit_waits_the_milliseconds_the_provider_asks_for() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let (provider, endpoint) = mock(move |received| { + if counter.fetch_add(1, Ordering::Relaxed) == 0 { + Reply::json(429, &json!({})) + .header("retry-after-ms", "400") + .header("retry-after", "30") + } else { + answered(received) + } + }); + let (agent, request, start) = (agent(ATTEMPT_TIMEOUT), question(), Instant::now()); + let mut last = None; + fast().evaluate_queue( + &[&request], + 1, + &|_| Ok(()), + |request| send(&agent, &endpoint, "k", request), + &mut |_, outcome| last = Some(outcome), + ); + let outcome = last.unwrap(); + assert!(outcome.result.is_ok()); + assert_eq!((outcome.retries, provider.received().len()), (1, 2)); + let waited = start.elapsed(); + assert!( + waited >= Duration::from_millis(400) && waited < Duration::from_secs(30), + "{waited:?}" + ); +} + +#[test] +fn a_failure_names_its_request_id_and_invalid_fields_but_never_the_provider_text() { + let (_provider, endpoint) = mock(|_| { + let detail = json!({"detail": [{"loc": ["body", "questions", "q", "criteria"], + "msg": "private text", "type": "missing", "input": "private input"}]}); + Reply::json(422, &detail).header(REQUEST_ID, "req_9") + }); + let error = send(&agent(ATTEMPT_TIMEOUT), &endpoint, "k", &question()).unwrap_err(); + assert_eq!( + error.to_string(), + "TypeSafe HTTP 422 (invalid request: body.questions.q.criteria missing); request was not retried; request id req_9" + ); +} diff --git a/src/units/answers.rs b/src/units/answers.rs index 3bd3d57..b4cd890 100644 --- a/src/units/answers.rs +++ b/src/units/answers.rs @@ -78,6 +78,7 @@ pub fn record(file: &mut FileResult, asked: &Asked, body: &Value) -> Result<()> version: questions::VERSION.into(), pass: question.pass, answer, + request_id: body["request_id"].as_str().map(str::to_owned), }); } Ok(()) diff --git a/src/units/tests/pipeline.rs b/src/units/tests/pipeline.rs index 0857284..f2091bd 100644 --- a/src/units/tests/pipeline.rs +++ b/src/units/tests/pipeline.rs @@ -139,8 +139,13 @@ struct Refusing { impl crate::transport::Evaluator for Refusing { fn evaluate(&mut self, request: &Value) -> Result { if request["jevgate"]["stage"] == self.stage { - let body = r#"{"detail":{"error_type":"max_tokens_exceeded"}}"#; - return Err(crate::provider_error::provider_error(400, Some(body), None).into()); + let refusal = crate::provider_error::Failure { + status: 400, + body: Some(r#"{"detail":{"error_type":"max_tokens_exceeded"}}"#), + ..Default::default() + }; + let error = crate::provider_error::provider_error(&crate::provider::TYPESAFE, refusal); + return Err(error.into()); } Ok(answer(request, self.level)) } diff --git a/tests/support/mock_provider.rs b/tests/support/mock_provider.rs new file mode 100644 index 0000000..b49a15a --- /dev/null +++ b/tests/support/mock_provider.rs @@ -0,0 +1,137 @@ +//! A provider on 127.0.0.1 for tests: each request gets the reply a closure +//! computes from it, over plain HTTP/1.1 with one request per connection, +//! and every request is recorded. +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + sync::{Arc, Mutex}, + time::Duration, +}; + +/// One request as the server received it. +#[derive(Clone, Debug)] +pub struct Received { + pub method: String, + pub path: String, + headers: Vec<(String, String)>, + pub body: String, +} + +impl Received { + /// A header's value, by case-insensitive name. + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + /// The body as JSON. + pub fn json(&self) -> serde_json::Value { + serde_json::from_str(&self.body).unwrap() + } +} + +/// A reply: status, extra headers, body, and how long to wait before sending it. +pub struct Reply { + pub status: u16, + pub headers: Vec<(&'static str, String)>, + pub body: String, + pub delay: Duration, +} + +impl Reply { + pub fn json(status: u16, body: &serde_json::Value) -> Self { + Self { + status, + headers: Vec::new(), + body: body.to_string(), + delay: Duration::ZERO, + } + } + + pub fn header(mut self, name: &'static str, value: &str) -> Self { + self.headers.push((name, value.to_owned())); + self + } +} + +type Respond = dyn Fn(&Received) -> Reply + Send + Sync; + +pub struct MockProvider { + /// `http://127.0.0.1:PORT`, the API root to point JevGate at. + pub url: String, + received: Arc>>, +} + +impl MockProvider { + pub fn start(respond: impl Fn(&Received) -> Reply + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let received = Arc::new(Mutex::new(Vec::new())); + let respond: Arc = Arc::new(respond); + let log = Arc::clone(&received); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let (log, respond) = (Arc::clone(&log), Arc::clone(&respond)); + std::thread::spawn(move || serve(stream, &log, respond.as_ref())); + } + }); + Self { url, received } + } + + /// Every request received so far, in order of arrival. + pub fn received(&self) -> Vec { + self.received.lock().unwrap().clone() + } +} + +fn serve(stream: TcpStream, log: &Mutex>, respond: &Respond) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + let mut words = line.split_whitespace(); + let (method, path) = ( + words.next().unwrap_or_default(), + words.next().unwrap_or_default(), + ); + let mut headers = Vec::new(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + let header = header.trim_end(); + if header.is_empty() { + break; + } + let (name, value) = header.split_once(':').unwrap(); + headers.push((name.trim().to_owned(), value.trim().to_owned())); + } + let length = headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map_or(0, |(_, value)| value.parse().unwrap()); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + let request = Received { + method: method.to_owned(), + path: path.to_owned(), + headers, + body: String::from_utf8(body).unwrap(), + }; + log.lock().unwrap().push(request.clone()); + let reply = respond(&request); + std::thread::sleep(reply.delay); + let mut out = stream; + let mut head = format!( + "HTTP/1.1 {} Mock\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + reply.status, + reply.body.len() + ); + for (name, value) in &reply.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + // The client may have given up waiting; a failed write is its business. + let _ = out.write_all(format!("{head}\r\n{}", reply.body).as_bytes()); +} From e0269be118fb117fddbba2492e18cf7e816c3ca0 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:44:03 -0300 Subject: [PATCH 007/306] Pace requests to 1,200 a minute, cap concurrency at 6, time out attempts after 20 s TypeSafe documents 1,200 requests a minute. On the corpus's largest runs six workers made 18 to 20 requests a second (0.3 s a request), just under it, and `--concurrency 8` would make about 27. Requests now start at least 50 ms apart across every worker and retry, and concurrency accepts 1 to 6. An attempt gave up only after 60 s; TypeSafe's SDKs wait 10 s. It now gives up after 20 s, and a timed-out request is still sent once more. A mock provider that answers late shows the retry. --- CHANGELOG.md | 1 + jevgate.schema.json | 4 +-- site/src/configuration.md | 2 +- site/src/troubleshooting.md | 2 +- src/config.rs | 14 +++++++- src/options/mod.rs | 11 +++--- src/transport/mod.rs | 36 +++++++++++++++++-- src/transport/tests.rs | 69 +++++++++++++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f51539..7a6ac27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver - Models: a model name without an `x.y.z` version is an alias, whose cached answers expire after `cache_ttl_secs`, and gateways' names are accepted (`typesafe/jev-1.13`, `~typesafe/jev-latest`, `typesafe-ai/jev`). TypeSafe's docs name models `jev` and `jev-1.13`; JevGate took those for pinned versions and rejected every answer, which names the version (`jev-1.13.0`), as coming from "a different pinned model". A pinned name still accepts only its own version, with or without a gateway's namespace. The default `jev-1.13.0` asks nothing again. - Cost: a run is priced by the model that answered each request, not by the name it asked for. A `jev-latest` run showed no cost, although TypeSafe answers it with `jev-1.13.0`. A response without `usage`, which a gateway need not send, is accepted, and the run's cost is shown as unknown rather than $0; its tokens stay out of the bytes-per-token calibration. The JSON report adds `paid_models` (input tokens by the model that answered them), `unmetered_requests` and `estimated_usd` (null when unknown). - Provider errors: an error names the provider's request id when it sent one (`x-typesafe-request-id`, else the response's own `id`), and each judgment in the report and each cached answer keeps the id of the request that answered it, to quote to the provider's support. A 402 says the credits are exhausted and where to add them. A 422 names each invalid field and its error type (`body.questions.q1.criteria missing`), never the provider's message or input, which can echo the source. A 404 suggests checking the model name, and a 413, a gateway's refusal of an oversized request, counts as beyond the model's context like TypeSafe's own `max_tokens_exceeded`. A retry waits as long as `retry-after-ms` asks, else `Retry-After` in seconds or as an HTTP date; JevGate read whole seconds only. The pause is still at most 30 seconds. +- Pacing: requests start at least 50 ms apart, TypeSafe's documented limit of 1,200 a minute, and `--concurrency` and `concurrency` accept 1 to 6, the default; a configuration with `concurrency = 7` or `8` is now an error. Six workers made 18 to 20 requests a second on the corpus's largest runs (0.3 s a request), so pacing leaves them as fast; eight would make about 27. Each attempt times out after 20 seconds instead of 60 (TypeSafe's SDKs wait 10), and a timed-out request is still sent once more. ## [0.25.0] - 2026-09-27 diff --git a/jevgate.schema.json b/jevgate.schema.json index 2e4ffb4..bc4437c 100644 --- a/jevgate.schema.json +++ b/jevgate.schema.json @@ -268,8 +268,8 @@ "type": "integer" }, "concurrency": { - "description": "Ceiling on simultaneous requests (1-8). Default: 6.", - "maximum": 8, + "description": "Ceiling on simultaneous requests (1-6). Default: 6.", + "maximum": 6, "minimum": 1, "type": "integer" }, diff --git a/site/src/configuration.md b/site/src/configuration.md index ff3ea88..23fc798 100644 --- a/site/src/configuration.md +++ b/site/src/configuration.md @@ -34,7 +34,7 @@ rules = { security = "consider" } # except these | `model` | `jev-1.13.0` | TypeSafe model; a pinned version keeps results repeatable | | `cache_ttl_secs` | `3600` | Cache lifetime for an alias: a model name without an `x.y.z` version, such as `jev-latest` or `jev-1.13`. Pinned versions such as `jev-1.13.0` never expire | | `max_requests` | unlimited | Ceiling on API attempts per invocation | -| `concurrency` | `6` | Ceiling on simultaneous requests (1–8) | +| `concurrency` | `6` | Ceiling on simultaneous requests (1–6). Requests also start at least 50 ms apart, TypeSafe's limit of 1,200 a minute | | `max_file_bytes` | `262144` | Files larger than this are reported as needs-context, never truncated; generated and vendored files are skipped instead | | `max_context_bytes` | `32768` | Ceiling on context bytes per request | diff --git a/site/src/troubleshooting.md b/site/src/troubleshooting.md index 3c03530..3613f3b 100644 --- a/site/src/troubleshooting.md +++ b/site/src/troubleshooting.md @@ -30,7 +30,7 @@ A provider error ends with the provider's request id when it sent one (`; reques **`Another JevGate session owns latest.json`** : Another `check` or `--watch` is running in the same repository. Stop it first. -Rate limits, overload and server errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts before the run gives up, waiting as long as the provider asks (`retry-after-ms`, or `Retry-After` in seconds or as a date, at most 30 seconds), and a timeout or dropped connection is retried once. +Rate limits, overload and server errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts before the run gives up, waiting as long as the provider asks (`retry-after-ms`, or `Retry-After` in seconds or as a date, at most 30 seconds). An attempt that has not answered within 20 seconds, or whose connection drops, is retried once. Requests start at least 50 ms apart, within TypeSafe's limit of 1,200 a minute. ## Many files are uncertain diff --git a/src/config.rs b/src/config.rs index 9f1384d..27290df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,7 @@ pub struct Config { pub rules: Rules, /// Ceiling on API attempts per invocation; flags can only lower it. Default: unlimited. pub max_requests: Option, - /// Ceiling on simultaneous requests (1-8). Default: 6. + /// Ceiling on simultaneous requests (1-6). Default: 6. pub concurrency: Option, /// Files larger than this are reported as needs-context, never truncated. Default: 262144. pub max_file_bytes: Option, @@ -623,4 +623,16 @@ mod tests { assert!(configured("[rules]\nmaintainability = \"sometimes\"\n", &[], &[]).is_err()); assert!(configured("[rules]\nnothing = \"review\"\n", &[], &[]).is_err()); } + + #[test] + fn concurrency_is_at_most_six_and_the_file_can_only_lower_it() { + assert_eq!(configured("", &[], &[]).unwrap().concurrency, 6); + assert_eq!( + configured("concurrency = 2", &[], &[]).unwrap().concurrency, + 2 + ); + for invalid in ["concurrency = 0", "concurrency = 7", "concurrency = 8"] { + assert!(configured(invalid, &[], &[]).is_err(), "{invalid}"); + } + } } diff --git a/src/options/mod.rs b/src/options/mod.rs index a11f4bb..2af2837 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -219,8 +219,8 @@ pub struct CheckArgs { /// ceiling this flag can only lower. #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=1000000), help_heading = BUDGETS)] pub max_requests: Option, - /// Maximum simultaneous TypeSafe requests (1-8) - #[arg(long, value_name = "N", default_value_t = 6, value_parser = clap::value_parser!(u32).range(1..=MAX_CONCURRENCY as i64), help_heading = BUDGETS)] + /// Maximum simultaneous requests (1-6) + #[arg(long, value_name = "N", default_value_t = MAX_CONCURRENCY, value_parser = clap::value_parser!(u32).range(1..=MAX_CONCURRENCY as i64), help_heading = BUDGETS)] pub concurrency: u32, /// Per-file read limit; a larger file is reported as needs-context, never truncated #[arg(long, value_name = "BYTES", default_value_t = DEFAULT_MAX_FILE_BYTES, value_parser = clap::value_parser!(u64).range(1..=1048576), help_heading = BUDGETS)] @@ -272,8 +272,11 @@ pub struct PathLevels { pub rules: BTreeMap>, } -/// Upper bound on simultaneous requests; rate-limit retries share one cooldown. -pub const MAX_CONCURRENCY: u32 = 8; +/// Upper bound on simultaneous requests, and the default: six workers made 18 +/// to 20 requests a second on the corpus's largest runs (0.3 s a request), +/// just under TypeSafe's limit of 1,200 a minute; eight would make about 27. +/// Rate-limit retries share one cooldown. +pub const MAX_CONCURRENCY: u32 = 6; /// Default read limit per file. Units are sent separately, so this bounds /// local reading rather than one request. Configuration and diff --git a/src/transport/mod.rs b/src/transport/mod.rs index a909644..e389230 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -21,8 +21,13 @@ const ATTEMPTS: u32 = 4; const INTERRUPTED_ATTEMPTS: u32 = 2; /// Longest provider-requested pause that is honored before a retry. const RETRY_AFTER_CAP: Duration = Duration::from_secs(30); -/// How long one attempt may take, from connecting to reading the answer. -const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(60); +/// How long one attempt may take, from connecting to reading the answer. A +/// request takes about 0.3 s and TypeSafe's SDKs wait 10 s per attempt; one +/// that has not answered in 20 s is sent again once, rather than holding a +/// worker for a minute. +const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20); +/// Requests start at least this far apart: 1,200 a minute, TypeSafe's limit. +const REQUEST_INTERVAL: Duration = Duration::from_millis(50); pub trait Evaluator { /// A new snapshot may retry after account access has been restored. @@ -247,6 +252,10 @@ struct ProviderAccess { edge_blocks: AtomicU16, cooldown: Mutex>, backoff: Duration, + /// The earliest time the next request may start, so that every worker's + /// sends together stay `interval` apart. + next_start: Mutex, + interval: Duration, /// The provider the requests go to, named in the messages. service: &'static Service, } @@ -259,6 +268,8 @@ impl Default for ProviderAccess { edge_blocks: AtomicU16::new(0), cooldown: Mutex::new(None), backoff: FIRST_BACKOFF, + next_start: Mutex::new(Instant::now()), + interval: REQUEST_INTERVAL, service: &TYPESAFE, } } @@ -335,6 +346,19 @@ impl ProviderAccess { } } + /// Take the next start time, `interval` after the one before, and sleep until it. + fn pace(&self) { + let start = { + let mut next = self.next_start.lock().unwrap(); + let start = (*next).max(Instant::now()); + *next = start + self.interval; + start + }; + if let Some(remaining) = start.checked_duration_since(Instant::now()) { + std::thread::sleep(remaining); + } + } + /// Exponential backoff with deterministic jitter, so reruns are reproducible /// while concurrent requests still spread out. fn backoff(&self, index: usize, retry: u32) -> Duration { @@ -353,8 +377,14 @@ impl ProviderAccess { let mut retry = 0; loop { self.wait(); + self.pace(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| send(request))) - .unwrap_or_else(|_| Err(anyhow::anyhow!("TypeSafe request worker failed"))); + .unwrap_or_else(|_| { + Err(anyhow::anyhow!( + "{} request worker failed", + self.service.label + )) + }); self.observe(&result); let Some((delay, attempts)) = result.as_ref().err().and_then(retry_delay) else { return (result, retry); diff --git a/src/transport/tests.rs b/src/transport/tests.rs index 61e6767..c8fdf63 100644 --- a/src/transport/tests.rs +++ b/src/transport/tests.rs @@ -15,9 +15,11 @@ fn failed(status: u16, body: Option<&str>, pause: Option) -> ProviderError provider_error(&TYPESAFE, failure) } +/// Retries and starts without the production pauses. fn fast() -> ProviderAccess { ProviderAccess { backoff: Duration::from_millis(1), + interval: Duration::ZERO, ..Default::default() } } @@ -515,3 +517,70 @@ fn a_failure_names_its_request_id_and_invalid_fields_but_never_the_provider_text "TypeSafe HTTP 422 (invalid request: body.questions.q.criteria missing); request was not retried; request id req_9" ); } + +#[test] +fn requests_start_an_interval_apart_across_workers() { + assert_eq!(REQUEST_INTERVAL * 1200, Duration::from_secs(60)); + let access = ProviderAccess { + interval: Duration::from_millis(20), + ..fast() + }; + let requests: Vec = (0..6).map(|i| json!({"index": i})).collect(); + let batch: Vec<&Value> = requests.iter().collect(); + let starts = Mutex::new(Vec::new()); + let before = Instant::now(); + access.evaluate_queue( + &batch, + MAX_WORKERS, + &|_| Ok(()), + |request| { + starts.lock().unwrap().push(Instant::now()); + Ok(request.clone()) + }, + &mut |_, outcome| assert!(outcome.result.is_ok()), + ); + let mut starts = starts.into_inner().unwrap(); + starts.sort(); + for (i, start) in starts.iter().enumerate() { + assert!( + start.duration_since(before) >= access.interval * i as u32, + "{i}" + ); + } +} + +/// Workers a queue may run at once. +const MAX_WORKERS: usize = crate::options::MAX_CONCURRENCY as usize; + +#[test] +fn a_slow_answer_times_out_and_is_sent_once_more() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let (provider, endpoint) = mock(move |received| { + let mut reply = answered(received); + if counter.fetch_add(1, Ordering::Relaxed) == 0 { + reply.delay = Duration::from_secs(2); + } + reply + }); + let (agent, request, start) = ( + agent(Duration::from_millis(300)), + question(), + Instant::now(), + ); + let mut last = None; + fast().evaluate_queue( + &[&request], + 1, + &|_| Ok(()), + |request| send(&agent, &endpoint, "k", request), + &mut |_, outcome| last = Some(outcome), + ); + let outcome = last.unwrap(); + assert!(outcome.result.is_ok(), "{:?}", outcome.result.err()); + assert_eq!((outcome.retries, provider.received().len()), (1, 2)); + assert!( + start.elapsed() < Duration::from_secs(2), + "the attempt gave up at its timeout" + ); +} From 6034aea33491b961cfba5c3b543041e577067fa3 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:03:34 -0300 Subject: [PATCH 008/306] Accept OpenRouter and Vercel AI Gateway keys People who already pay for OpenRouter or Vercel AI Gateway can run JevGate without a TypeSafe account, as they can Abide and Qlty: both gateways serve TypeSafe's API and the same model at the same price. JevGate read only TYPESAFE_API_KEY and sent every request to api.typesafe.ai. The provider follows the key. `jevgate auth login` asks which kind of key it is (`--provider` for scripts) and saves it with its provider; a check reads TYPESAFE_API_KEY, OPENROUTER_API_KEY or AI_GATEWAY_API_KEY from the environment, then `--env-file`, then the saved key. The repository's .env is read only for TYPESAFE_API_KEY: a gateway's key there is usually the application's own. `auth status` shows the provider, the endpoint and the keys set but not used; the headline says `via OpenRouter`. A key goes only to its own provider. jevgate.toml and .env cannot name a host; a key with another provider's prefix (`sk-or-`, `vck_`) is refused; JEVGATE_BASE_URL, from the environment only, takes https or loopback http for a self-hosted proxy and is announced on stderr. A gateway's saved key is stored as ` `, which 0.25 refuses as invalid rather than send to TypeSafe; a plain `provider` file beside it lets a check choose its default model (`typesafe/jev-1.13`, `typesafe-ai/jev`) before reading the key, and a mismatch stops the run instead of sending anything. Tested end to end against a local server in each gateway's shape (OpenRouter's `id`, `provider` and `usage.cost`; an answer without usage); no gateway has been called. --- CHANGELOG.md | 1 + README.md | 4 +- jevgate.schema.json | 2 +- site/src/ci.md | 8 +- site/src/configuration.md | 5 +- site/src/install.md | 12 +- site/src/privacy-and-cost.md | 3 +- site/src/quick-start.md | 2 +- site/src/troubleshooting.md | 10 +- src/auth/file.rs | 89 ++++++-- src/auth/mod.rs | 235 +++++++++++++++------ src/auth/native_unix.rs | 11 +- src/auth/provider.rs | 74 ------- src/auth/secret.rs | 4 +- src/auth/sources.rs | 309 ++++++++++++++++++++++------ src/auth/store.rs | 98 +++++++-- src/auth/tests.rs | 365 ++++++++++++++++++++++++++------- src/auth/verify.rs | 111 ++++++++++ src/check.rs | 9 +- src/command.rs | 4 + src/config.rs | 2 +- src/evaluate.rs | 1 + src/init.rs | 4 +- src/mcp.rs | 4 +- src/options/commands.rs | 27 ++- src/options/mod.rs | 30 ++- src/output.rs | 13 +- src/provider.rs | 270 +++++++++++++++++++++++- src/schema/report.rs | 3 + src/tests/mod.rs | 50 +---- src/transport/mod.rs | 46 +++-- src/transport/tests.rs | 15 +- tests/cli/gateway.rs | 306 +++++++++++++++++++++++++++ tests/cli/main.rs | 8 + tests/support/mock_provider.rs | 61 +++++- 35 files changed, 1744 insertions(+), 452 deletions(-) delete mode 100644 src/auth/provider.rs create mode 100644 src/auth/verify.rs create mode 100644 tests/cli/gateway.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6ac27..8537e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver - The output says what fails. The agent text marks each finding that fails the gate `(fails the gate)`, and says when reviews did not fail it because their rules are still being measured and how to make them fail it. A GitHub warning, a SARIF result and a GitLab issue for such a finding say so, with how often its rule and level were right. The JSON report records how the gate counted each new finding as `gate` (`fails`, `measuring` or `advisory`), and `fail_on_mature` says what `mature` stands for among the selected rules; the HTML report and the MCP server's `jevgate_findings` show both. - `jevgate rules` shows the levels that fail by default and how often each rule's reviews and considers were right on unseen projects, with the number labeled; `--format json` adds each level's labels on unseen and tuned projects as `maturity`. - Hardcoded values no longer runs by default: 6 of its 37 labeled reviews and considers were right on projects JevGate was never tuned on (16%), against 47 of 85 on the projects it was tuned on (55%). Without it, a default run asks 44% fewer first-pass requests (22,370 to 12,623 on 94 corpus projects) and uploads 39% fewer bytes. It stays in the `maintainability` group and in `all`; `--rule default --rule hardcoded-values` adds it to the default rules. +- Gateway keys: an OpenRouter or Vercel AI Gateway key works as a TypeSafe key does; both gateways serve TypeSafe's API and the same model at the same price. `jevgate auth login` asks which kind of key it is (`--provider typesafe|openrouter|vercel` answers it for scripts) and saves the key with its provider; a check reads `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY` from the environment, in that order, then `--env-file` (the same names; the repository's `.env` is read only for `TYPESAFE_API_KEY`, since a gateway's key there is usually the application's own), then the saved key. `jevgate auth status` shows the provider, where requests go and the keys set but not used, and the headline says `via OpenRouter` when a gateway answers. The default model follows the key: `typesafe/jev-1.13` on OpenRouter (the 1.13 line; `~typesafe/jev-latest` would move to new major versions) and `typesafe-ai/jev` on Vercel, both aliases whose answers expire after `cache_ttl_secs`; TypeSafe's stays `jev-1.13.0`, so nothing is asked again with a TypeSafe key. A key goes only to its own provider: nothing in `jevgate.toml` or `.env` chooses the host, a key that starts as another provider's do (`sk-or-`, `vck_`) is refused, and `JEVGATE_BASE_URL`, read only from the environment, points requests at a self-hosted proxy over https (or http on this machine). A gateway's key is saved as its provider's name and the key, which versions before 0.26 refuse rather than send to TypeSafe. Tested against a local server in each gateway's shape; no request to either gateway has been made yet. - Models: a model name without an `x.y.z` version is an alias, whose cached answers expire after `cache_ttl_secs`, and gateways' names are accepted (`typesafe/jev-1.13`, `~typesafe/jev-latest`, `typesafe-ai/jev`). TypeSafe's docs name models `jev` and `jev-1.13`; JevGate took those for pinned versions and rejected every answer, which names the version (`jev-1.13.0`), as coming from "a different pinned model". A pinned name still accepts only its own version, with or without a gateway's namespace. The default `jev-1.13.0` asks nothing again. - Cost: a run is priced by the model that answered each request, not by the name it asked for. A `jev-latest` run showed no cost, although TypeSafe answers it with `jev-1.13.0`. A response without `usage`, which a gateway need not send, is accepted, and the run's cost is shown as unknown rather than $0; its tokens stay out of the bytes-per-token calibration. The JSON report adds `paid_models` (input tokens by the model that answered them), `unmetered_requests` and `estimated_usd` (null when unknown). - Provider errors: an error names the provider's request id when it sent one (`x-typesafe-request-id`, else the response's own `id`), and each judgment in the report and each cached answer keeps the id of the request that answered it, to quote to the provider's support. A 402 says the credits are exhausted and where to add them. A 422 names each invalid field and its error type (`body.questions.q1.criteria missing`), never the provider's message or input, which can echo the source. A 404 suggests checking the model name, and a 413, a gateway's refusal of an oversized request, counts as beyond the model's context like TypeSafe's own `max_tokens_exceeded`. A retry waits as long as `retry-after-ms` asks, else `Retry-After` in seconds or as an HTTP date; JevGate read whole seconds only. The pause is still at most 30 seconds. diff --git a/README.md b/README.md index 0109a6c..6854897 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,13 @@ cargo binstall jevgate # any platform, with cargo-binstall cargo install jevgate --locked # build from source; needs Rust 1.90 or later ``` -Releases have binaries for Linux, macOS and Windows with checksums and build provenance. Reviewing needs a [TypeSafe API key](https://console.typesafe.ai/settings/keys). [Install](https://tech-byte-frontier.github.io/jevgate/install.html) covers verifying a download, shell completions and man pages. +Releases have binaries for Linux, macOS and Windows with checksums and build provenance. Reviewing needs an API key from [TypeSafe](https://console.typesafe.ai/settings/keys), [OpenRouter](https://openrouter.ai/settings/keys) or [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys), which serve the same model at the same price. [Install](https://tech-byte-frontier.github.io/jevgate/install.html) covers verifying a download, shell completions and man pages. ## Quick start ```sh jevgate init # write a commented jevgate.toml for this repository -jevgate auth login # validate and save your TypeSafe API key +jevgate auth login # validate and save your API key: TypeSafe, OpenRouter or Vercel jevgate check --dry-run --show-requests # see exactly what would be uploaded; free and offline jevgate check --report # review, then open a local HTML dashboard jevgate baseline # accept today's findings; later checks fail only on new ones diff --git a/jevgate.schema.json b/jevgate.schema.json index bc4437c..3fa5b33 100644 --- a/jevgate.schema.json +++ b/jevgate.schema.json @@ -322,7 +322,7 @@ "type": "integer" }, "model": { - "description": "TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it.", + "description": "Model, as the key's provider names it; a pinned version keeps results repeatable. `--model` overrides it. Default: `jev-1.13.0` with a TypeSafe key, `typesafe/jev-1.13` with an OpenRouter key, `typesafe-ai/jev` with a Vercel AI Gateway key.", "type": "string" }, "rules": { diff --git a/site/src/ci.md b/site/src/ci.md index b1d2a6a..c0c1351 100644 --- a/site/src/ci.md +++ b/site/src/ci.md @@ -25,7 +25,7 @@ The action installs a checked release binary, keeps `.jevgate/cache` in the Acti `--format github` annotates the changed lines with each finding. A finding that fails the gate is an error; the others are warnings, and a warning whose rule and level are still being measured says so, with how often such findings were right. A Markdown table goes to the job summary, and the usual text goes to the log. The full JSON report is always at `.jevgate/latest.json` if you want to keep it as an artifact. - **Changed files only:** `--base` reviews what changed since the fork point with that revision, the same files a pull request diff shows, plus uncommitted and untracked files. It needs the history, so check out with `fetch-depth: 0`. When no supported file changed, the run passes without any request. -- **Cache:** answers are stored under a hash of the exact request: source, questions and model. Restoring an older cache is always safe, and unchanged code costs nothing on the next run. +- **Cache:** answers are stored under a hash of the exact request: source, questions and model. Restoring an older cache is always safe, and unchanged code costs nothing on the next run. A gateway's model names are aliases, so with an OpenRouter or Vercel AI Gateway key answers expire after `cache_ttl_secs` (an hour by default); raise it to reuse answers across runs further apart, at the price of noticing a new model version later. - **Advisory or blocking:** by default only the rules and levels measured right at least 80% of the time on projects JevGate was never tuned on fail the check ([what fails by default](configuration.md#what-fails-the-check-by-default)); the other findings are warnings. `fail_on = ["review"]` in `jevgate.toml` or `--fail-on review` fails on every review, and `fail_on = ["none"]` or `--fail-on none` reports findings without failing. A run that could not finish (missing key, provider rejection, request budget reached) still exits 2, so an outage never passes as a clean review. - **A policy the change cannot edit:** a pull request can edit `jevgate.toml`. To apply the reviewed policy of the base branch instead, read it with `--config`: @@ -36,7 +36,7 @@ The action installs a checked release binary, keeps `.jevgate/cache` in the Acti - **Forks:** GitHub withholds secrets from pull requests opened from forks, so there the run exits 2 with "No API key configured". Skip the job for forks, or run it only on branches of the repository. - **Budgets:** `max_requests` caps the API attempts of one run. Reaching it leaves the run incomplete instead of passing on partial evidence. `--dry-run` counts the planned requests the cache already answers, so its estimate covers only what the cache lacks; follow-ups depend on answers and are not counted. -- **Transient failures:** rate limits, overload and server or edge errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts; a timeout or dropped connection is retried once, since the first send may have run. +- **Transient failures:** rate limits, overload and server or edge errors (HTTP 408, 429, 500, 502–504, 520–524, 529) are retried up to four attempts; an attempt that has not answered in 20 seconds, or whose connection drops, is retried once, since the first send may have run. - **Report-only paths:** give tooling its own level with `[[scope]]` (below), so scripts are reported while product code gates. Before each commit, with [pre-commit](https://pre-commit.com), review what is staged: @@ -49,7 +49,7 @@ repos: - id: jevgate-system # the jevgate on PATH; `jevgate` builds it with Rust instead ``` -On GitLab, a merge request pipeline can show the findings in the merge request with a Code Quality report. Set `TYPESAFE_API_KEY` as a masked CI/CD variable: +On GitLab, a merge request pipeline can show the findings in the merge request with a Code Quality report. Set `TYPESAFE_API_KEY` as a masked CI/CD variable (or `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY` for a gateway's key): ```yaml jevgate: @@ -70,4 +70,4 @@ jevgate: - if: $CI_PIPELINE_SOURCE == "merge_request_event" ``` -Other CI systems work the same way: install with `install.sh` or `cargo binstall`, set `TYPESAFE_API_KEY`, keep `.jevgate/cache` between runs, and read the exit code or the JSON report. +Other CI systems work the same way: install with `install.sh` or `cargo binstall`, set `TYPESAFE_API_KEY` (or a gateway's variable), keep `.jevgate/cache` between runs, and read the exit code or the JSON report. diff --git a/site/src/configuration.md b/site/src/configuration.md index 23fc798..6f8170a 100644 --- a/site/src/configuration.md +++ b/site/src/configuration.md @@ -31,7 +31,7 @@ rules = { security = "consider" } # except these | `[[scope]]` | none | `paths` (globs), with `fail_on` for every rule and `rules` for rules or groups, as above; `off` is not accepted (use `upload_deny`). The last scope that matches a file and addresses a rule wins; flags win over scopes | | `fail_on` | `["mature"]` | The level for rules without their own, like `--fail-on` | | `include_tests` | `false` | Judge tests, like `--include-tests` | -| `model` | `jev-1.13.0` | TypeSafe model; a pinned version keeps results repeatable | +| `model` | the key's provider's | The model, as the key's provider names it: `jev-1.13.0` for TypeSafe, `typesafe/jev-1.13` for OpenRouter, `typesafe-ai/jev` for Vercel AI Gateway. A pinned version keeps results repeatable; a repository that sets it for one provider needs `--model` with another provider's key | | `cache_ttl_secs` | `3600` | Cache lifetime for an alias: a model name without an `x.y.z` version, such as `jev-latest` or `jev-1.13`. Pinned versions such as `jev-1.13.0` never expire | | `max_requests` | unlimited | Ceiling on API attempts per invocation | | `concurrency` | `6` | Ceiling on simultaneous requests (1–6). Requests also start at least 50 ms apart, TypeSafe's limit of 1,200 a minute | @@ -47,5 +47,8 @@ The default level, `mature`, fails the check only on the rules and levels measur Any level you set replaces the default exactly as it says, for the rules and paths it addresses: `fail_on = ["review"]` (or `--fail-on review`) fails on every review, as releases before 0.26 did; `--fail-on security=consider` sets one group and leaves the others at `mature`; `mature` itself can be set, such as for one group after a stricter `fail_on`. A later release can mark more levels mature as labels accumulate, or fewer; set `fail_on` to keep a fixed policy. Undecided answers never fail the check under `mature`. A `jevgate.toml` written by `jevgate init` before 0.26 sets `maintainability = "review"` and `tests = "review"`: those lines keep every review of the two groups failing the check, and judge hardcoded values. Delete them for the default rules and gate. +## Keys and where requests go + +`jevgate.toml` has no key for the provider or its address: the change under review can edit that file, so it must not be able to send your key elsewhere. The provider follows the key ([Install](install.md) lists the three kinds), and a key goes only to its own provider. `JEVGATE_BASE_URL`, read only from the environment, replaces the provider's API root for a self-hosted proxy or a test server: `https://` to any host, or `http://` only to `localhost`, `127.0.0.1` or `[::1]`. Each check says on stderr when it is set, and `jevgate auth status` checks the key against `/v1/models` there. The [configuration reference](reference/configuration.md) lists every key with its type, and the rule names and levels it accepts. diff --git a/site/src/install.md b/site/src/install.md index e6d01b9..258ebc3 100644 --- a/site/src/install.md +++ b/site/src/install.md @@ -11,4 +11,14 @@ Each [release](https://github.com/Tech-Byte-Frontier/jevgate/releases) has binar `jevgate completions bash|zsh|fish|powershell` prints a shell completion script and `jevgate man` a man page; Homebrew installs both. -Reviewing needs a [TypeSafe API key](https://console.typesafe.ai/settings/keys). Git is needed only for `--base` and the staleness rule. +Reviewing needs an API key. Jev, the model JevGate asks, is served by TypeSafe and by two gateways, at the same price: + +| Key | Create one at | Environment variable | Default model | +|---|---|---|---| +| TypeSafe | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) | `TYPESAFE_API_KEY` | `jev-1.13.0` | +| OpenRouter | [openrouter.ai](https://openrouter.ai/settings/keys) | `OPENROUTER_API_KEY` | `typesafe/jev-1.13` | +| Vercel AI Gateway | [the Vercel dashboard](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys) | `AI_GATEWAY_API_KEY` | `typesafe-ai/jev` | + +`jevgate auth login` asks which kind of key it is and saves it with its provider; in CI, set the variable from a secret. A check uses the first key it finds: the environment's (in the order above), then `--env-file`, then the saved key; `jevgate auth status` shows which one and the keys it leaves unused. Only TypeSafe offers a pinned version (`jev-1.13.0`): the gateways' names are aliases, whose cached answers expire after `cache_ttl_secs` (an hour by default). + +Git is needed only for `--base` and the staleness rule. diff --git a/site/src/privacy-and-cost.md b/site/src/privacy-and-cost.md index e6aa074..1ae4230 100644 --- a/site/src/privacy-and-cost.md +++ b/site/src/privacy-and-cost.md @@ -3,6 +3,7 @@ - **What is uploaded:** only the selected units of source, bounded by `upload_allow` and `upload_deny`. `--dry-run --show-requests` prints every initial request body without credentials or network access. - **Instruction files:** uploaded only when a documentation rule is selected, and still bounded by the upload patterns. - **README opening:** a sensitive-data finding about error details is asked who reads the error text, with the first 1,200 characters of the root README's prose (images, badges and HTML left out), unless `upload_deny` covers the README or `upload_allow` leaves it out. -- **Credentials:** a check reads `TYPESAFE_API_KEY` from the environment, then `--env-file` or the repository's `.env`, then the key saved by `jevgate auth login` (OS credential store, or an owner-only file). The key is never printed or written to reports. +- **Credentials:** a check uses the first key it finds: `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY` in the environment, then the file `--env-file` names (the same variables) or `TYPESAFE_API_KEY` in the repository's `.env`, then the key saved by `jevgate auth login` (OS credential store, or an owner-only file). The key is never printed or written to reports, and it goes only to its own provider: a key that starts as another provider's do (`sk-or-`, `vck_`) is refused, and nothing in the repository can choose the host ([Configuration](configuration.md#keys-and-where-requests-go)). +- **Where requests go:** to TypeSafe, or through OpenRouter or Vercel AI Gateway, which pass TypeSafe's API through. TypeSafe commits not to train models on user data and offers zero data retention to enterprise customers ([Privacy Policy](https://typesafe.ai/legal/privacy-policy)). A gateway also handles each request under its own terms; Vercel AI Gateway can apply zero data retention per request, which JevGate does not ask for. - **Cost:** every run prints its input tokens and an estimated cost, priced by the model that answered: Jev 1.13 costs $0.042 per million input tokens, and output is free. When a response reports no token usage, the cost is shown as unknown, never as $0. Cached answers cost nothing. - **Secrets:** out of scope on purpose, because judging secrets would mean uploading them. Use a local secret scanner. diff --git a/site/src/quick-start.md b/site/src/quick-start.md index d9717e1..636efa1 100644 --- a/site/src/quick-start.md +++ b/site/src/quick-start.md @@ -2,7 +2,7 @@ ```sh jevgate init # write a commented jevgate.toml for this repository -jevgate auth login # validate and save your TypeSafe API key +jevgate auth login # validate and save your API key: TypeSafe, OpenRouter or Vercel jevgate check --dry-run --show-requests # see exactly what would be uploaded; free and offline jevgate check --report # review, then open a local HTML dashboard jevgate baseline # accept today's findings; later checks fail only on new ones diff --git a/site/src/troubleshooting.md b/site/src/troubleshooting.md index 3613f3b..bbaa81e 100644 --- a/site/src/troubleshooting.md +++ b/site/src/troubleshooting.md @@ -4,8 +4,14 @@ Exit code 2 means the run could not finish, or the configuration or command line is invalid. The message says which; an outage never passes as a clean review. -**`No API key configured. Run jevgate auth login, set TYPESAFE_API_KEY, or provide --env-file PATH`** -: A check reads `TYPESAFE_API_KEY` from the environment, then `--env-file` or the repository's `.env`, then the key saved by `jevgate auth login`. `jevgate auth status` shows which one a check would use and verifies it. On GitHub Actions, pull requests from forks don't receive secrets: skip the job for them (`if: github.event.pull_request.head.repo.full_name == github.repository`). +**`No API key configured. Run jevgate auth login, set TYPESAFE_API_KEY (or OPENROUTER_API_KEY, AI_GATEWAY_API_KEY), or provide --env-file PATH`** +: A check reads `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY` or `AI_GATEWAY_API_KEY` from the environment, then `--env-file` (or `TYPESAFE_API_KEY` in the repository's `.env`), then the key saved by `jevgate auth login`. A gateway's key in the repository's `.env` is read only with `--env-file .env`, since it is usually the application's own; the message says so when one is there. `jevgate auth status` shows which key a check would use and verifies it. On GitHub Actions, pull requests from forks don't receive secrets: skip the job for them (`if: github.event.pull_request.head.repo.full_name == github.repository`). + +**`TYPESAFE_API_KEY environment variable: the key was issued by OpenRouter (it starts with sk-or-), not by TypeSafe`** +: A key goes only to the provider that issued it. Set it in the variable the message names, or save it with `jevgate auth login --provider openrouter`. + +**`OpenRouter HTTP 404 (not found; check the model name)`** +: A model name is sent as written, and each provider has its own: `jev-1.13.0` on TypeSafe, `typesafe/jev-1.13` on OpenRouter, `typesafe-ai/jev` on Vercel AI Gateway. A `model` in `jevgate.toml` written for one provider needs `--model` with another provider's key. **`Cannot find revision …; in CI, fetch it (for example fetch-depth: 0)`**, or **`… and HEAD share no history`** : `--base` needs the history back to the fork point. Check out with `fetch-depth: 0`. diff --git a/src/auth/file.rs b/src/auth/file.rs index b50b48b..b3c0da5 100644 --- a/src/auth/file.rs +++ b/src/auth/file.rs @@ -1,11 +1,14 @@ -//! The fallback is confined to an owner-only directory and never writes a repository .env. -use super::secret::{MAX_KEY_BYTES, Secret}; +//! The fallback is confined to an owner-only directory and never writes a +//! repository .env. Beside it, a plain file names the saved key's provider. +use super::secret::MAX_STORED_BYTES; +use crate::provider::Provider; use anyhow::{Context, Result, ensure}; use std::{ fs, io::Read, path::{Path, PathBuf}, }; +use zeroize::Zeroizing; pub fn credential_path() -> Result { if let Some(path) = std::env::var_os("JEVGATE_CONFIG_DIR") { @@ -76,14 +79,15 @@ fn private_metadata(_path: &Path, _directory: bool) -> Result { ) } -pub fn load(path: &Path) -> Result> { +/// The saved credential's text, as `store` wrote it. +pub fn load(path: &Path) -> Result>> { if !path.try_exists()? && !path.is_symlink() { return Ok(None); } private_metadata(path.parent().context("Missing credential directory")?, true)?; let metadata = private_metadata(path, false)?; ensure!( - metadata.len() <= MAX_KEY_BYTES as u64, + metadata.len() <= MAX_STORED_BYTES as u64, "Saved credential file is too large" ); let mut options = fs::OpenOptions::new(); @@ -93,23 +97,23 @@ pub fn load(path: &Path) -> Result> { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); } - let mut value = zeroize::Zeroizing::new(String::new()); + let mut value = Zeroizing::new(String::new()); options .open(path)? - .take((MAX_KEY_BYTES + 1) as u64) + .take((MAX_STORED_BYTES + 1) as u64) .read_to_string(&mut value) .map_err(|_| anyhow::anyhow!("Cannot read saved credential; run jevgate auth login"))?; ensure!( - value.len() <= MAX_KEY_BYTES, + value.len() <= MAX_STORED_BYTES, "Saved credential file is too large" ); - Secret::parse(std::mem::take(&mut *value)).map(Some) + Ok(Some(value)) } -pub fn save(path: &Path, secret: &Secret) -> Result<()> { +pub fn save(path: &Path, text: &str) -> Result<()> { #[cfg(not(unix))] { - let _ = (path, secret); + let _ = (path, text); anyhow::bail!( "Protected-file storage is unavailable; use the system credential store or TYPESAFE_API_KEY" ); @@ -117,13 +121,9 @@ pub fn save(path: &Path, secret: &Secret) -> Result<()> { #[cfg(unix)] { use std::io::Write; - use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + use std::os::unix::fs::OpenOptionsExt; let parent = path.parent().context("Missing credential directory")?; - fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(parent)?; - private_metadata(parent, true)?; + private_directory(parent)?; if path.exists() || path.is_symlink() { private_metadata(path, false)?; } @@ -137,7 +137,7 @@ pub fn save(path: &Path, secret: &Secret) -> Result<()> { .mode(0o600) .open(&temporary)?; let result = (|| -> Result<()> { - file.write_all(secret.expose().as_bytes())?; + file.write_all(text.as_bytes())?; file.sync_all()?; fs::rename(&temporary, path)?; Ok(()) @@ -158,3 +158,58 @@ pub fn remove(path: &Path) -> Result { fs::remove_file(path)?; Ok(true) } + +/// The directory of saved credentials, created owner-only. +fn private_directory(directory: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(directory)?; + private_metadata(directory, true)?; + } + #[cfg(not(unix))] + fs::create_dir_all(directory)?; + Ok(()) +} + +/// The file beside the saved credential that names its provider, so a check +/// can choose its model before it reads the credential itself. +fn provider_path(credential: &Path) -> PathBuf { + credential.with_file_name("provider") +} + +/// The provider recorded beside the saved credential; none when no key was +/// saved with one, as before 0.26. +pub fn recorded_provider(credential: &Path) -> Option { + let path = provider_path(credential); + if path.is_symlink() { + return None; + } + let mut name = String::new(); + fs::File::open(path) + .ok()? + .take(64) + .read_to_string(&mut name) + .ok()?; + Provider::named(name.trim()) +} + +pub fn record_provider(credential: &Path, provider: Provider) -> Result<()> { + let path = provider_path(credential); + private_directory(path.parent().context("Missing credential directory")?)?; + ensure!( + !path.is_symlink(), + "The saved key's provider record must be a regular file" + ); + fs::write(&path, provider.name()).context("Could not record the saved key's provider") +} + +pub fn forget_provider(credential: &Path) -> Result<()> { + match fs::remove_file(provider_path(credential)) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()), + _ => Ok(()), + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 5bcf34a..b6f4c37 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -4,33 +4,38 @@ mod file; not(any(target_os = "macos", target_os = "ios", target_os = "android")) ))] mod native_unix; -mod provider; mod secret; pub(crate) mod sources; mod store; +mod verify; -use anyhow::{Result, ensure}; +use crate::provider::{Endpoint, Provider}; +use anyhow::{Result, bail, ensure}; use clap::{Args, Subcommand}; -use provider::{TypeSafe, Verifier}; use secret::Secret; -use std::{io::IsTerminal, path::PathBuf}; +use std::{ + io::{BufRead, IsTerminal, Write}, + path::PathBuf, +}; use store::{Backend, NativeBackend, SavedCredentials, StorageMode}; +use verify::Verifier; #[derive(Subcommand)] pub enum AuthCommand { - /// Validate a TypeSafe API key and save it for every repository + /// Validate an API key from TypeSafe, OpenRouter or Vercel AI Gateway and save it for every repository /// - /// Prompts without echo, checks the key with TypeSafe (no source is sent), - /// and saves it in the OS credential store, or in an owner-only file where - /// no store is available. Create a key at - /// https://console.typesafe.ai/settings/keys. + /// Asks which kind of key it is, prompts without echo, checks the key + /// with its provider (no source is sent), and saves it with its provider + /// in the OS credential store, or in an owner-only file where no store is + /// available. Create a key at https://console.typesafe.ai/settings/keys, + /// https://openrouter.ai/settings/keys or in the Vercel dashboard. Login(LoginArgs), /// Show which credential a check would use; exit 0 when it works, 2 otherwise /// - /// Verifies the key with TypeSafe unless --offline. No source is sent and - /// the key is never printed. + /// Verifies the key with its provider unless --offline. No source is sent + /// and the key is never printed. Status(StatusArgs), - /// Remove saved credentials; TYPESAFE_API_KEY and repository .env files are left alone + /// Remove saved credentials; environment variables and repository .env files are left alone Logout, } @@ -39,6 +44,9 @@ pub struct LoginArgs { /// Read one key from stdin instead of prompting, for scripts #[arg(long)] with_key: bool, + /// The key's provider [default: asked on a terminal; typesafe with --with-key] + #[arg(long, value_enum)] + provider: Option, /// Where to save the key [default: JEVGATE_CREDENTIAL_STORE, else auto] /// /// `auto` uses the OS credential store and, on Unix, falls back to an @@ -50,13 +58,13 @@ pub struct LoginArgs { #[derive(Args)] pub struct StatusArgs { - /// Inspect this credential file instead of the repository .env; TYPESAFE_API_KEY still wins + /// Inspect this credential file instead of the repository .env; keys in the environment still win #[arg(long, value_name = "FILE")] env_file: Option, - /// Report the credential source without contacting TypeSafe + /// Report the credential source without contacting the provider #[arg(long)] offline: bool, - /// Print source, configured, connection_checked, authenticated and error as JSON (never the key) + /// Print source, provider, endpoint, configured, connection_checked, authenticated, error and unused as JSON (never the key) #[arg(long)] json: bool, } @@ -70,96 +78,196 @@ pub fn run(command: AuthCommand) -> Result { } fn login(args: LoginArgs) -> Result { - let key = if args.with_key { + let (provider, key) = if args.with_key { ensure!( !std::io::stdin().is_terminal(), "Pipe the key into jevgate auth login --with-key, or omit --with-key for hidden terminal entry" ); - secret::read_stdin(std::io::stdin().lock())? + let provider = args.provider.unwrap_or_default(); + (provider, secret::read_stdin(std::io::stdin().lock())?) } else { - ensure!( - std::io::stdin().is_terminal() && std::io::stderr().is_terminal(), - "Interactive login requires a terminal. For automation use jevgate auth login --with-key < key-file, or set TYPESAFE_API_KEY" - ); - note!("Create an API key at https://console.typesafe.ai/settings/keys"); - let value = rpassword::prompt_password("TypeSafe API key (hidden): ").map_err(|_| { - anyhow::anyhow!("Could not read hidden input; use --with-key to read from stdin") - })?; - Secret::parse(value)? + prompt(args.provider)? }; + let service = provider.service(); + sources::refuse_foreign(provider, &key, "jevgate auth login")?; + let endpoint = Endpoint::new(provider)?; let mode = args .storage .map(Ok) .unwrap_or_else(StorageMode::configured)?; let store = SavedCredentials::native(mode)?; - note!("Validating with TypeSafe; no source code is uploaded."); - let saved = validate_and_save(&TypeSafe, &store, &key)?; + note!( + "Validating with {}; no source code is uploaded.", + service.label + ); + let saved = validate_and_save(&endpoint, &store, provider, &key)?; if saved.fallback { note!("System credential store unavailable; using owner-only file storage (unencrypted)."); } - say!("API key verified and saved in {}.", saved.description); + say!( + "{} API key verified and saved in {}.", + service.label, + saved.description + ); say!("Ready: jevgate check . --dry-run"); report_override(); Ok(0) } +/// Ask on the terminal which kind of key it is, unless `--provider` said, +/// then read the key without echo. +fn prompt(chosen: Option) -> Result<(Provider, Secret)> { + ensure!( + std::io::stdin().is_terminal() && std::io::stderr().is_terminal(), + "Interactive login requires a terminal. For automation use jevgate auth login --with-key [--provider NAME] < key-file, or set TYPESAFE_API_KEY, OPENROUTER_API_KEY or AI_GATEWAY_API_KEY" + ); + let provider = match chosen { + Some(provider) => provider, + None => ask_provider(&mut std::io::stdin().lock(), &mut std::io::stderr())?, + }; + let service = provider.service(); + note!("Create an API key at {}", service.keys_page); + let value = rpassword::prompt_password(format!("{} API key (hidden): ", service.label)) + .map_err(|_| { + anyhow::anyhow!("Could not read hidden input; use --with-key to read from stdin") + })?; + Ok((provider, Secret::parse(value)?)) +} + +/// Answers `ask_provider` takes before it gives up. +const MAX_ANSWERS: usize = 3; + +/// Ask which kind of key it is until the answer names one: its number or its +/// name, or nothing for TypeSafe. +fn ask_provider(input: &mut impl BufRead, output: &mut impl Write) -> Result { + let menu: Vec = Provider::ALL + .iter() + .enumerate() + .map(|(i, provider)| format!("{} {}", i + 1, provider.service().label)) + .collect(); + for _ in 0..MAX_ANSWERS { + write!(output, "Key kind: {} [1]: ", menu.join(", "))?; + output.flush()?; + let mut answer = String::new(); + if input.read_line(&mut answer)? == 0 { + break; + } + if let Some(provider) = choice(answer.trim()) { + return Ok(provider); + } + writeln!( + output, + "Answer 1, 2 or 3, or typesafe, openrouter or vercel." + )?; + } + bail!("No key kind given; pass --provider typesafe, openrouter or vercel") +} + +/// The provider an answer names by number or name; empty means TypeSafe. +fn choice(answer: &str) -> Option { + if answer.is_empty() { + return Some(Provider::Typesafe); + } + let by_number = answer + .parse::() + .ok() + .and_then(|n| Provider::ALL.get(n.checked_sub(1)?).copied()); + by_number.or_else(|| Provider::named(&answer.to_ascii_lowercase())) +} + fn validate_and_save( verifier: &impl Verifier, store: &SavedCredentials, + provider: Provider, key: &Secret, ) -> Result { verifier.verify(key)?; - store.save(key) + store.save(provider, key) +} + +/// What `auth status` found: where the key is, its provider and endpoint, +/// the keys set besides it, and whether it works. +struct Status { + source: Option, + provider: Option, + endpoint: Option, + unused: Vec, + result: Result<()>, + checked: bool, } fn status(args: StatusArgs) -> Result { let path = environment_path(args.env_file.as_ref())?; - let credential = sources::resolve(&path, args.env_file.is_some()); - let (source, result) = match credential { - Ok(credential) => { - let result = if args.offline { + let explicit = args.env_file.is_some(); + let unused = sources::unused(&path, explicit).unwrap_or_default(); + let found = sources::resolve(&path, explicit) + .and_then(|credential| Ok((Endpoint::new(credential.provider)?, credential))); + let status = match found { + Ok((endpoint, credential)) => Status { + source: Some(credential.source), + provider: Some(credential.provider), + endpoint: Some(endpoint.describe()), + unused, + result: if args.offline { Ok(()) } else { - TypeSafe.verify(&credential.key) - }; - (Some(credential.source), result) - } - Err(error) => (None, Err(error)), + endpoint.verify(&credential.key) + }, + checked: !args.offline, + }, + Err(error) => Status { + source: None, + provider: None, + endpoint: None, + unused, + result: Err(error), + checked: false, + }, }; - let checked = !args.offline && source.is_some(); - let code = if result.is_ok() { 0 } else { 2 }; - print_status(&args, source, result, checked)?; + let code = if status.result.is_ok() { 0 } else { 2 }; + print_status(&args, status)?; Ok(code) } -fn print_status( - args: &StatusArgs, - source: Option, - result: Result<()>, - checked: bool, -) -> Result<()> { +fn print_status(args: &StatusArgs, status: Status) -> Result<()> { if args.json { say!( "{}", serde_json::to_string_pretty(&serde_json::json!({ - "source":source,"configured":source.is_some(),"connection_checked":checked, - "authenticated":provider::authenticated(&result, checked), - "error":result.err().map(|e|format!("{e:#}")), + "source": status.source, + "provider": status.provider.map(Provider::name), + "endpoint": status.endpoint, + "configured": status.source.is_some(), + "connection_checked": status.checked, + "authenticated": verify::authenticated(&status.result, status.checked), + "error": status.result.as_ref().err().map(|e| format!("{e:#}")), + "unused": status.unused, }))? ); return Ok(()); } - if let Some(source) = source { + if let (Some(source), Some(provider), Some(endpoint)) = + (&status.source, status.provider, &status.endpoint) + { + let service = provider.service(); say!("Credential source: {source}"); + say!( + "Provider: {} at {endpoint}; default model {}", + service.label, + service.default_model + ); + } + if !status.unused.is_empty() { + say!( + "Also set, not used: {} (the first key found is used)", + status.unused.join("; ") + ); } - match result { + match status.result { + Ok(()) if args.offline => say!("Connection: not checked (--offline)."), Ok(()) => say!( - "{}", - if args.offline { - "Connection: not checked (--offline)." - } else { - "Connection: authenticated with TypeSafe. No source code was uploaded." - } + "Connection: authenticated with {}. No source code was uploaded.", + status.provider.unwrap_or_default().service().label ), Err(error) => note!("Authentication: {error:#}"), } @@ -195,14 +303,7 @@ fn environment_path(selected: Option<&PathBuf>) -> Result { } fn report_override() { - let override_source = (|| -> Result> { - if sources::environment()?.is_some() { - return Ok(Some("TYPESAFE_API_KEY environment variable".into())); - } - let path = environment_path(None)?; - Ok(sources::key_from_file(&path)?.map(|_| format!("repository .env: {}", path.display()))) - })(); - match override_source { + match environment_path(None).and_then(|path| sources::override_source(&path)) { Ok(Some(source)) => say!( "Current override: {source}. Saved credentials are used when this override is absent." ), diff --git a/src/auth/native_unix.rs b/src/auth/native_unix.rs index beff136..f9f56bb 100644 --- a/src/auth/native_unix.rs +++ b/src/auth/native_unix.rs @@ -1,10 +1,10 @@ //! Secret Service reads and deletion never invoke its Unlock or Prompt methods. //! The higher-level keyring library can open a dialog even when just reading. -use super::secret::Secret; use anyhow::{Result, ensure}; use secret_service::{EncryptionType, blocking::SecretService}; use std::collections::HashMap; use zbus::blocking::{Connection, Proxy}; +use zeroize::Zeroizing; fn attributes() -> HashMap<&'static str, &'static str> { HashMap::from([("service", "jevgate"), ("username", "typesafe-api-key")]) @@ -19,15 +19,16 @@ fn unlocked_item<'a>( Ok(items.unlocked.pop()) } -pub fn get() -> Result> { - (|| -> Result> { +/// The saved credential's text, as `store` wrote it. +pub fn get() -> Result>> { + (|| -> Result>> { let service = SecretService::connect(EncryptionType::Dh)?; let Some(item) = unlocked_item(&service)? else { return Ok(None); }; - let bytes = zeroize::Zeroizing::new(item.get_secret()?); + let bytes = Zeroizing::new(item.get_secret()?); let value = std::str::from_utf8(&bytes)?; - Secret::parse(value.to_owned()).map(Some) + Ok(Some(Zeroizing::new(value.to_owned()))) })() .map_err(|_| anyhow::anyhow!( "Cannot read the system credential store; unlock it and retry, or use TYPESAFE_API_KEY or an --env-file. Run jevgate auth login to configure credentials" diff --git a/src/auth/provider.rs b/src/auth/provider.rs deleted file mode 100644 index 3997b54..0000000 --- a/src/auth/provider.rs +++ /dev/null @@ -1,74 +0,0 @@ -use super::secret::Secret; -use anyhow::{Result, bail, ensure}; -use serde_json::Value; -use std::time::Duration; - -pub trait Verifier { - fn verify(&self, key: &Secret) -> Result<()>; -} -pub struct TypeSafe; -#[derive(Debug)] -pub struct RejectedKey(u16); -impl std::fmt::Display for RejectedKey { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "TypeSafe rejected this API key (HTTP {}); create a key at https://console.typesafe.ai/settings/keys and run jevgate auth login", - self.0 - ) - } -} -impl std::error::Error for RejectedKey {} - -pub fn authenticated(result: &Result<()>, checked: bool) -> Option { - if !checked { - return None; - } - match result { - Ok(()) => Some(true), - Err(error) if error.is::() => Some(false), - Err(_) => None, - } -} -impl Verifier for TypeSafe { - fn verify(&self, key: &Secret) -> Result<()> { - let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(15))) - .max_redirects(0) - .build() - .into(); - let response = agent - .get("https://api.typesafe.ai/v1/models") - .header("Authorization", format!("Bearer {}", key.expose())) - .call(); - let mut response = match response { - Ok(response) => response, - Err(ureq::Error::StatusCode(code)) => return http_error(code), - Err(_) => bail!( - "Could not reach TypeSafe or the request timed out; check the connection and retry. The credential was not verified" - ), - }; - let body: Value = response.body_mut().with_config().limit(1_048_576).read_json() - .map_err(|_| anyhow::anyhow!("TypeSafe returned invalid or oversized model-list JSON; credential was not verified"))?; - validate_models(&body) - } -} - -pub fn http_error(code: u16) -> Result<()> { - match code { - 401 | 403 => Err(RejectedKey(code).into()), - _ => bail!( - "TypeSafe returned HTTP {code}; credential was not verified and the request was not retried" - ), - } -} - -pub fn validate_models(body: &Value) -> Result<()> { - ensure!( - body["models"].as_array().is_some_and(|models| models - .iter() - .all(|model| model["name"].as_str().is_some_and(|name| !name.is_empty()))), - "TypeSafe returned an unexpected model list; credential was not verified" - ); - Ok(()) -} diff --git a/src/auth/secret.rs b/src/auth/secret.rs index 3e6aa86..242d978 100644 --- a/src/auth/secret.rs +++ b/src/auth/secret.rs @@ -3,6 +3,8 @@ use std::io::Read; use zeroize::Zeroizing; pub const MAX_KEY_BYTES: usize = 4096; +/// A saved credential holds a key and, for a gateway, its provider's name. +pub const MAX_STORED_BYTES: usize = MAX_KEY_BYTES + 32; // Intentionally no Debug/Display/Serialize implementation. pub struct Secret(Zeroizing); @@ -14,7 +16,7 @@ impl Secret { !trimmed.is_empty() && trimmed.len() <= MAX_KEY_BYTES && trimmed.bytes().all(|b| b.is_ascii_graphic()), - "Invalid TYPESAFE_API_KEY: provide one nonempty key without spaces or embedded newlines" + "Invalid API key: provide one nonempty key without spaces or embedded newlines" ); Ok(Self(Zeroizing::new(trimmed.to_owned()))) } diff --git a/src/auth/sources.rs b/src/auth/sources.rs index d6d6847..e172b60 100644 --- a/src/auth/sources.rs +++ b/src/auth/sources.rs @@ -1,78 +1,264 @@ +//! Where a check finds its key: the environment, then a credential file, then +//! the key saved by `jevgate auth login`. The first key found is used, and it +//! goes only to the provider that issued it. use super::{ secret::Secret, - store::{NativeBackend, SavedCredentials, StorageMode}, + store::{self, NativeBackend, SavedCredentials, SavedKey, StorageMode}, }; +use crate::provider::Provider; use anyhow::{Context, Result, bail, ensure}; use std::{io::Read, path::Path}; pub struct Credential { pub key: Secret, + pub provider: Provider, pub source: String, } -pub fn resolve(path: &Path, explicit: bool) -> Result { - let environment = environment()?; - resolve_with(environment, path, explicit, || { - SavedCredentials::::native(StorageMode::configured()?)?.get() - }) +/// The credential file a check reads: the one `--env-file` names, else the +/// repository's `.env`. +#[derive(Clone, Copy)] +pub struct CredentialFile<'a> { + pub path: &'a Path, + pub explicit: bool, } -pub fn environment() -> Result> { - match std::env::var("TYPESAFE_API_KEY") { - Ok(value) if value.trim().is_empty() => Ok(None), - Ok(value) => Ok(Some(value)), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(_) => bail!("TYPESAFE_API_KEY must contain UTF-8 text"), +impl CredentialFile<'_> { + /// The providers whose keys this file may hold: all three in a file named + /// with `--env-file`; only TYPESAFE_API_KEY in the repository's `.env`, + /// where a gateway's key is usually the application's own and would bill + /// its account. + fn providers(self) -> &'static [Provider] { + if self.explicit { + &Provider::ALL + } else { + &Provider::ALL[..1] + } } -} -pub fn resolve_with( - environment: Option, - path: &Path, - explicit: bool, - saved: impl FnOnce() -> Result>, -) -> Result { - if let Some(value) = environment { - return Ok(Credential { - key: Secret::parse(value)?, - source: "TYPESAFE_API_KEY environment variable".into(), - }); + /// Its keys for those providers, in the order a check reads them. + fn keys(self) -> Result> { + match read_limited(self.path)? { + Some(text) => parse_keys(&text, self.providers()), + None => Ok(Vec::new()), + } } - if let Some(key) = key_from_file(path)? { - return Ok(Credential { - key, - source: format!( - "{}: {}", - if explicit { - "--env-file" - } else { - "repository .env" - }, - path.display() + + fn source(self, provider: Provider) -> String { + let kind = if self.explicit { + "--env-file" + } else { + "repository .env" + }; + match provider { + Provider::Typesafe => format!("{kind}: {}", self.path.display()), + gateway => format!( + "{kind}: {} ({})", + self.path.display(), + gateway.service().variable ), - }); + } + } + + /// For the repository's `.env`, the gateway keys it holds that a check + /// does not read, as a hint after "No API key configured". + fn unread_hint(self) -> String { + if self.explicit { + return String::new(); + } + let unread: Vec<&str> = read_limited(self.path) + .ok() + .flatten() + .and_then(|text| parse_keys(&text, &Provider::ALL[1..]).ok()) + .into_iter() + .flatten() + .map(|(provider, _)| provider.service().variable) + .collect(); + if unread.is_empty() { + return String::new(); + } + format!( + ". The repository .env's {} is read only with --env-file .env", + unread.join(" and ") + ) + } +} + +/// Where the key a check will use is, found without reading the saved key. +pub enum Located { + Key(Credential), + /// The key saved by `jevgate auth login`, of the provider recorded beside it. + Saved(Provider), +} + +impl Located { + pub fn provider(&self) -> Provider { + match self { + Self::Key(credential) => credential.provider, + Self::Saved(provider) => *provider, + } + } +} + +/// Keys set in the environment, in the order a check reads them; an empty +/// variable counts as unset. +pub fn environment() -> Result> { + let mut keys = Vec::new(); + for provider in Provider::ALL { + let name = provider.service().variable; + match std::env::var(name) { + Ok(value) if value.trim().is_empty() => {} + Ok(value) => keys.push((provider, value)), + Err(std::env::VarError::NotPresent) => {} + Err(_) => bail!("{name} must contain UTF-8 text"), + } + } + Ok(keys) +} + +fn environment_source(provider: Provider) -> String { + format!("{} environment variable", provider.service().variable) +} + +/// The first key in a check's order: the environment's, then the credential +/// file's; else the saved key, whose provider `saved` gives. +pub fn locate( + environment: Vec<(Provider, String)>, + file: CredentialFile<'_>, + saved: impl FnOnce() -> Provider, +) -> Result { + if let Some((provider, value)) = environment.into_iter().next() { + let key = Secret::parse(value)?; + return credential(provider, key, environment_source(provider)).map(Located::Key); + } + if let Some((provider, key)) = file.keys()?.into_iter().next() { + return credential(provider, key, file.source(provider)).map(Located::Key); } ensure!( - !explicit, - "Selected --env-file is missing or has no TYPESAFE_API_KEY; correct the path or run jevgate auth login without --env-file" + !file.explicit, + "Selected --env-file is missing or has no TYPESAFE_API_KEY, OPENROUTER_API_KEY or AI_GATEWAY_API_KEY; correct the path or run jevgate auth login without --env-file" ); - match saved() - .context("Saved credential unavailable; run jevgate auth login or set TYPESAFE_API_KEY")? - { - Some((key, source)) => Ok(Credential { key, source }), - None => bail!( - "No API key configured. Run jevgate auth login, set TYPESAFE_API_KEY, or provide --env-file PATH" - ), + Ok(Located::Saved(saved())) +} + +/// A key found for `provider`, unless its prefix shows another provider +/// issued it: sent to the wrong host, it would fail there and hand that host +/// the key. +fn credential(provider: Provider, key: Secret, source: String) -> Result { + refuse_foreign(provider, &key, &source)?; + Ok(Credential { + key, + provider, + source, + }) +} + +/// Fail when `key` starts as another provider's keys do; `holder` names where it was found. +pub fn refuse_foreign(provider: Provider, key: &Secret, holder: &str) -> Result<()> { + if let Some(issuer) = Provider::issuer(key.expose()).filter(|issuer| *issuer != provider) { + let issued = issuer.service(); + bail!( + "{holder}: the key was issued by {} (it starts with {}), not by {}; set it as {}, or save it with jevgate auth login --provider {}", + issued.label, + issued.key_prefix.unwrap_or_default(), + provider.service().label, + issued.variable, + issuer.name() + ); } + Ok(()) } -pub fn key_from_file(path: &Path) -> Result> { - match read_limited(path)? { - Some(text) => parse_key(&text), - None => Ok(None), +/// The provider recorded beside the saved key; TypeSafe when none is, as for +/// keys saved before 0.26. +fn recorded() -> Provider { + store::recorded_provider().unwrap_or_default() +} + +/// The provider of the key a check will use, found without reading the saved +/// key, since a check's default model depends on it; TypeSafe when no key is +/// found or its source cannot be read, which then fails where it is used. +pub fn planned_provider(path: &Path, explicit: bool) -> Provider { + let file = CredentialFile { path, explicit }; + environment() + .and_then(|environment| locate(environment, file, recorded)) + .map_or(Provider::Typesafe, |located| located.provider()) +} + +/// The key a check uses; the saved key is read only when nothing before it +/// holds one. +pub fn resolve(path: &Path, explicit: bool) -> Result { + let file = CredentialFile { path, explicit }; + match locate(environment()?, file, recorded)? { + Located::Key(credential) => Ok(credential), + Located::Saved(provider) => saved(provider, file, || { + SavedCredentials::::native(StorageMode::configured()?)?.get() + }), } } +/// The saved key, which must be of the provider recorded beside it: a check +/// planned its model for that provider. +pub fn saved( + recorded: Provider, + file: CredentialFile<'_>, + read: impl FnOnce() -> Result>, +) -> Result { + let saved = read() + .context("Saved credential unavailable; run jevgate auth login or set TYPESAFE_API_KEY")? + .with_context(|| { + format!( + "No API key configured. Run jevgate auth login, set TYPESAFE_API_KEY (or OPENROUTER_API_KEY, AI_GATEWAY_API_KEY), or provide --env-file PATH{}", + file.unread_hint() + ) + })?; + ensure!( + saved.provider == recorded, + "The saved key is for {}, but the provider recorded beside it is {}; run jevgate auth login again", + saved.provider.service().label, + recorded.service().label + ); + Ok(Credential { + key: saved.key, + provider: saved.provider, + source: saved.description, + }) +} + +/// The keys set besides the one a check uses, in the order a check reads them. +pub fn unused(path: &Path, explicit: bool) -> Result> { + let file = CredentialFile { path, explicit }; + let mut found: Vec = environment()? + .into_iter() + .map(|(provider, _)| environment_source(provider)) + .collect(); + found.extend( + file.keys()? + .into_iter() + .map(|(provider, _)| file.source(provider)), + ); + if let Some(provider) = store::recorded_provider() { + found.push(format!( + "the {} key saved by jevgate auth login", + provider.service().label + )); + } + Ok(found.into_iter().skip(1).collect()) +} + +/// The key that a check uses instead of the saved one, when there is one: +/// an environment variable or the repository's `.env`. +pub fn override_source(path: &Path) -> Result> { + let file = CredentialFile { + path, + explicit: false, + }; + Ok(match locate(environment()?, file, Provider::default)? { + Located::Key(credential) => Some(credential.source), + Located::Saved(_) => None, + }) +} + /// The file's text, at most 64 KiB and zeroed on drop; `None` when it does not exist. fn read_limited(path: &Path) -> Result>> { let metadata = match std::fs::metadata(path) { @@ -97,23 +283,30 @@ fn read_limited(path: &Path) -> Result>> { Ok(Some(text)) } -/// The single `TYPESAFE_API_KEY=` definition, optionally exported or quoted. -fn parse_key(text: &str) -> Result> { - let mut key = None; +/// The keys a credential file defines for `providers`, in the order a check +/// reads them: each variable once, optionally exported or quoted. +fn parse_keys(text: &str, providers: &[Provider]) -> Result> { + let mut keys: Vec<(Provider, Secret)> = Vec::new(); for line in text.lines() { let line = line.trim().strip_prefix("export ").unwrap_or(line.trim()); let Some((name, value)) = line.split_once('=') else { continue; }; - if name.trim() != "TYPESAFE_API_KEY" { + let Some(provider) = providers + .iter() + .copied() + .find(|provider| provider.service().variable == name.trim()) + else { continue; - } + }; ensure!( - key.is_none(), - "Credential file contains duplicate TYPESAFE_API_KEY definitions" + !keys.iter().any(|(found, _)| *found == provider), + "Credential file contains duplicate {} definitions", + provider.service().variable ); let value = value.trim().trim_matches(['\'', '"']); - key = Some(Secret::parse(value.to_owned())?); + keys.push((provider, Secret::parse(value.to_owned())?)); } - Ok(key) + keys.sort_by_key(|(provider, _)| Provider::ALL.iter().position(|p| p == provider)); + Ok(keys) } diff --git a/src/auth/store.rs b/src/auth/store.rs index c65fef5..b91d55e 100644 --- a/src/auth/store.rs +++ b/src/auth/store.rs @@ -1,7 +1,9 @@ use super::{file, secret::Secret}; +use crate::provider::Provider; use anyhow::{Context, Result, bail, ensure}; use clap::ValueEnum; use std::{io::IsTerminal, path::PathBuf}; +use zeroize::Zeroizing; #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub enum StorageMode { @@ -23,9 +25,10 @@ impl StorageMode { } } +/// Where the saved credential's text lives: the OS store, or a fake in tests. pub trait Backend { - fn get(&self) -> Result>; - fn set(&self, secret: &Secret) -> Result<()>; + fn get(&self) -> Result>>; + fn set(&self, text: &str) -> Result<()>; fn delete(&self) -> Result; } @@ -43,7 +46,7 @@ impl NativeBackend { } } impl Backend for NativeBackend { - fn get(&self) -> Result> { + fn get(&self) -> Result>> { #[cfg(all( unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")) @@ -54,20 +57,19 @@ impl Backend for NativeBackend { not(any(target_os = "macos", target_os = "ios", target_os = "android")) )))] match self.entry()?.get_password() { - Ok(value) => Secret::parse(value).map(Some), + Ok(value) => Ok(Some(Zeroizing::new(value))), Err(keyring::Error::NoEntry) => Ok(None), Err(_) => bail!( "Cannot read the system credential store; unlock it or run jevgate auth login" ), } } - fn set(&self, secret: &Secret) -> Result<()> { + fn set(&self, text: &str) -> Result<()> { self.entry()? - .set_password(secret.expose()) + .set_password(text) .map_err(|_| anyhow::anyhow!("Cannot save to the system credential store"))?; ensure!( - self.get()? - .is_some_and(|saved| saved.expose() == secret.expose()), + self.get()?.is_some_and(|saved| *saved == text), "System credential store did not retain the credential" ); Ok(()) @@ -92,6 +94,35 @@ impl Backend for NativeBackend { } } +/// The text a saved key is stored as: the bare key for TypeSafe, as before +/// 0.26, else the provider's name, a space and the key. Versions before 0.26 +/// refuse a value with a space as an invalid key instead of sending a +/// gateway's key to TypeSafe. +fn stored_text(provider: Provider, key: &Secret) -> Zeroizing { + Zeroizing::new(match provider { + Provider::Typesafe => key.expose().to_owned(), + gateway => format!("{} {}", gateway.name(), key.expose()), + }) +} + +/// A saved key and its provider, from the text `stored_text` wrote. +pub(super) fn saved_key(text: &str) -> Result<(Provider, Secret)> { + let text = text.trim(); + let Some((name, key)) = text.split_once(' ') else { + return Ok((Provider::Typesafe, Secret::parse(text.to_owned())?)); + }; + let provider = Provider::named(name) + .context("The saved credential names an unknown provider; run jevgate auth login")?; + Ok((provider, Secret::parse(key.to_owned())?)) +} + +/// A key saved by `jevgate auth login`, and where it was found. +pub struct SavedKey { + pub provider: Provider, + pub key: Secret, + pub description: String, +} + pub struct SavedCredentials { pub backend: B, pub path: PathBuf, @@ -115,28 +146,53 @@ impl SavedCredentials { }) } } + +/// The provider recorded beside the saved key, read without the key itself; +/// none when no key was saved with one, as before 0.26. +pub fn recorded_provider() -> Option { + file::credential_path() + .ok() + .and_then(|path| file::recorded_provider(&path)) +} + impl SavedCredentials { - pub fn get(&self) -> Result> { + pub fn get(&self) -> Result> { // A fallback written during a keyring outage must not later expose an older keyring key. if self.mode != StorageMode::Keyring - && let Some(key) = file::load(&self.path)? + && let Some(text) = file::load(&self.path)? { - return Ok(Some(( + let (provider, key) = saved_key(&text)?; + let description = format!("protected file: {}", self.path.display()); + return Ok(Some(SavedKey { + provider, key, - format!("protected file: {}", self.path.display()), - ))); + description, + })); } if self.mode == StorageMode::File { return Ok(None); } - Ok(self - .backend - .get()? - .map(|key| (key, "system credential store".into()))) + let Some(text) = self.backend.get()? else { + return Ok(None); + }; + let (provider, key) = saved_key(&text)?; + Ok(Some(SavedKey { + provider, + key, + description: "system credential store".into(), + })) } - pub fn save(&self, secret: &Secret) -> Result { + + /// Save the key with its provider, then record the provider beside it. + pub fn save(&self, provider: Provider, secret: &Secret) -> Result { + let location = self.save_text(&stored_text(provider, secret))?; + file::record_provider(&self.path, provider)?; + Ok(location) + } + + fn save_text(&self, text: &str) -> Result { if self.mode != StorageMode::File { - match self.backend.set(secret) { + match self.backend.set(text) { Ok(()) => { file::remove(&self.path).context("Key saved in system credential store, but an older fallback file could not be removed")?; return Ok(SavedLocation { @@ -148,12 +204,13 @@ impl SavedCredentials { Err(_) => (), } } - file::save(&self.path, secret)?; + file::save(&self.path, text)?; Ok(SavedLocation { description: format!("protected file: {}", self.path.display()), fallback: self.mode == StorageMode::Auto, }) } + pub fn remove(&self) -> Result { let native = if self.mode == StorageMode::File { Ok(false) @@ -161,6 +218,7 @@ impl SavedCredentials { self.backend.delete() }; let removed_file = file::remove(&self.path)?; + file::forget_provider(&self.path)?; Ok(Removal { file: removed_file, keyring: native.as_ref().copied().unwrap_or(false), diff --git a/src/auth/tests.rs b/src/auth/tests.rs index db6ef5f..b12e757 100644 --- a/src/auth/tests.rs +++ b/src/auth/tests.rs @@ -1,8 +1,15 @@ +//! Saving, finding and checking keys: the credential store and its file +//! fallback, the order a check reads keys in, each provider's key check, and +//! the login question. use super::*; +use crate::provider::{OPENROUTER, TYPESAFE, VERCEL}; +use sources::{CredentialFile, Located}; use std::{ cell::{Cell, RefCell}, path::Path, }; +use store::SavedKey; +use zeroize::Zeroizing; #[derive(Default)] struct FakeStore { @@ -11,14 +18,14 @@ struct FakeStore { writes: Cell, } impl Backend for FakeStore { - fn get(&self) -> Result> { + fn get(&self) -> Result>> { ensure!(!self.unavailable.get(), "unavailable"); - self.secret.borrow().clone().map(Secret::parse).transpose() + Ok(self.secret.borrow().clone().map(Zeroizing::new)) } - fn set(&self, key: &Secret) -> Result<()> { + fn set(&self, text: &str) -> Result<()> { ensure!(!self.unavailable.get(), "unavailable"); self.writes.set(self.writes.get() + 1); - *self.secret.borrow_mut() = Some(key.expose().into()); + *self.secret.borrow_mut() = Some(text.into()); Ok(()) } fn delete(&self) -> Result { @@ -41,20 +48,30 @@ fn store(root: &Path, mode: StorageMode) -> SavedCredentials { } } +fn key(value: &str) -> Secret { + Secret::parse(value.into()).unwrap() +} + +/// The saved key's value. +fn saved_value(saved: &SavedCredentials) -> String { + saved.get().unwrap().unwrap().key.expose().to_owned() +} + /// A store that already holds `old-key`, and the `new-key` meant to replace it. fn replacing_old_key(root: &Path) -> (SavedCredentials, Secret) { let saved = store(root, StorageMode::Auto); *saved.backend.secret.borrow_mut() = Some("old-key".into()); - (saved, Secret::parse("new-key".into()).unwrap()) + (saved, key("new-key")) } #[test] fn validation_failure_preserves_the_previous_credential() { let project = crate::tests::Project::new(); let (saved, key) = replacing_old_key(&project.0); - assert!(validate_and_save(&Verification(false), &saved, &key).is_err()); + let provider = Provider::Typesafe; + assert!(validate_and_save(&Verification(false), &saved, provider, &key).is_err()); assert_eq!(saved.backend.writes.get(), 0); - assert_eq!(saved.get().unwrap().unwrap().0.expose(), "old-key"); + assert_eq!(saved_value(&saved), "old-key"); assert!(!saved.path.exists()); } @@ -62,16 +79,56 @@ fn validation_failure_preserves_the_previous_credential() { fn successful_login_and_logout_use_the_system_store() { let project = crate::tests::Project::new(); let saved = store(&project.0, StorageMode::Auto); - let key = Secret::parse("new-key".into()).unwrap(); - let location = validate_and_save(&Verification(true), &saved, &key).unwrap(); + let provider = Provider::Typesafe; + let location = validate_and_save(&Verification(true), &saved, provider, &key("new-key")); + let location = location.unwrap(); assert_eq!(location.description, "system credential store"); assert!(!location.fallback && !saved.path.exists()); - assert_eq!(saved.get().unwrap().unwrap().0.expose(), "new-key"); + assert_eq!(saved_value(&saved), "new-key"); let removed = saved.remove().unwrap(); assert!(removed.keyring && !removed.keyring_error); assert!(saved.get().unwrap().is_none()); } +#[test] +fn a_gateway_key_is_saved_with_its_provider_where_older_versions_refuse_it() { + let project = crate::tests::Project::new(); + let saved = store(&project.0, StorageMode::Auto); + let gateway_key = key("sk-or-v1-private"); + saved.save(Provider::Openrouter, &gateway_key).unwrap(); + let text = saved.backend.secret.borrow().clone().unwrap(); + assert_eq!(text, "openrouter sk-or-v1-private"); + assert!( + Secret::parse(text).is_err(), + "0.25 parses the stored value as a bare key" + ); + let found = saved.get().unwrap().unwrap(); + assert_eq!( + (found.provider, found.key.expose()), + (Provider::Openrouter, "sk-or-v1-private") + ); + assert_eq!( + file::recorded_provider(&saved.path), + Some(Provider::Openrouter) + ); + saved + .save(Provider::Typesafe, &key("typesafe-key")) + .unwrap(); + assert_eq!( + saved.backend.secret.borrow().as_deref(), + Some("typesafe-key") + ); + assert_eq!( + file::recorded_provider(&saved.path), + Some(Provider::Typesafe) + ); + saved.remove().unwrap(); + assert_eq!(file::recorded_provider(&saved.path), None); + for invalid in ["gateway sk-or-v1-x", "openrouter two words"] { + assert!(store::saved_key(invalid).is_err(), "{invalid}"); + } +} + #[cfg(unix)] #[test] fn fallback_is_private_survives_store_recovery_and_can_migrate_back() { @@ -79,8 +136,8 @@ fn fallback_is_private_survives_store_recovery_and_can_migrate_back() { let project = crate::tests::Project::new(); let (saved, key) = replacing_old_key(&project.0); saved.backend.unavailable.set(true); - let location = validate_and_save(&Verification(true), &saved, &key).unwrap(); - assert!(location.fallback); + let location = validate_and_save(&Verification(true), &saved, Provider::Typesafe, &key); + assert!(location.unwrap().fallback); assert_eq!( std::fs::metadata(&saved.path).unwrap().permissions().mode() & 0o777, 0o600 @@ -94,10 +151,10 @@ fn fallback_is_private_survives_store_recovery_and_can_migrate_back() { 0o700 ); saved.backend.unavailable.set(false); - assert_eq!(saved.get().unwrap().unwrap().0.expose(), "new-key"); - saved.save(&key).unwrap(); + assert_eq!(saved_value(&saved), "new-key"); + saved.save(Provider::Typesafe, &key).unwrap(); assert!(!saved.path.exists()); - assert_eq!(saved.backend.get().unwrap().unwrap().expose(), "new-key"); + assert_eq!(*saved.backend.get().unwrap().unwrap(), "new-key"); } #[cfg(unix)] @@ -106,53 +163,152 @@ fn keyring_only_mode_never_falls_back_and_partial_logout_is_reported() { let project = crate::tests::Project::new(); let mut saved = store(&project.0, StorageMode::Keyring); saved.backend.unavailable.set(true); - let key = Secret::parse("test-key".into()).unwrap(); - assert!(saved.save(&key).is_err()); + let key = key("test-key"); + assert!(saved.save(Provider::Typesafe, &key).is_err()); assert!(!saved.path.exists()); saved.mode = StorageMode::File; - saved.save(&key).unwrap(); + saved.save(Provider::Typesafe, &key).unwrap(); saved.mode = StorageMode::Auto; let removed = saved.remove().unwrap(); assert!(removed.file && removed.keyring_error); assert!(!saved.path.exists()); } +/// The first key a check finds with `environment` set and `file` as its +/// credential file; the saved key's provider is Vercel. +fn located(environment: &[(Provider, &str)], file: CredentialFile<'_>) -> Result { + let environment = environment + .iter() + .map(|(provider, value)| (*provider, value.to_string())) + .collect(); + sources::locate(environment, file, || Provider::Vercel) +} + +/// The found key's provider, value and source. +fn found(located: Result) -> (Provider, String, String) { + match located.unwrap() { + Located::Key(credential) => ( + credential.provider, + credential.key.expose().to_owned(), + credential.source, + ), + Located::Saved(provider) => (provider, String::new(), "saved".into()), + } +} + #[test] -fn precedence_is_environment_then_selected_file_then_saved_credentials() { +fn keys_are_read_from_the_environment_then_the_credential_file_then_the_saved_key() { let project = crate::tests::Project::new(); - let file = project.0.join(".env"); - project.write(".env", "TYPESAFE_API_KEY=repo-key\n"); - let env = sources::resolve_with(Some("environment-key".into()), &file, true, || { - panic!("must not read saved credentials") - }) - .unwrap(); - assert_eq!(env.key.expose(), "environment-key"); - let repo = sources::resolve_with(None, &file, false, || { - panic!("must not read saved credentials") - }) - .unwrap(); - assert_eq!(repo.key.expose(), "repo-key"); + let path = project.0.join(".env"); + let repository = CredentialFile { + path: &path, + explicit: false, + }; + let selected = CredentialFile { + path: &path, + explicit: true, + }; + project.write( + ".env", + "OPENROUTER_API_KEY=sk-or-file\nTYPESAFE_API_KEY=file-key\n", + ); + let everything = [ + (Provider::Openrouter, "sk-or-environment"), + (Provider::Vercel, "vck_environment"), + ]; + let (provider, value, source) = found(located(&everything, repository)); + assert_eq!( + (provider, value.as_str()), + (Provider::Openrouter, "sk-or-environment") + ); + assert_eq!(source, "OPENROUTER_API_KEY environment variable"); + let (provider, value, _) = found(located(&[], selected)); + assert_eq!( + (provider, value.as_str()), + (Provider::Typesafe, "file-key"), + "TypeSafe's key comes first in a file too" + ); + project.write(".env", "OPENROUTER_API_KEY=sk-or-file\nUNRELATED=keep-me\n"); + let (provider, value, source) = found(located(&[], selected)); + assert_eq!( + (provider, value.as_str()), + (Provider::Openrouter, "sk-or-file") + ); + assert!(source.starts_with("--env-file:") && source.ends_with("(OPENROUTER_API_KEY)")); + let (provider, _, source) = found(located(&[], repository)); + assert_eq!( + (provider, source.as_str()), + (Provider::Vercel, "saved"), + "the repository .env is read only for TYPESAFE_API_KEY" + ); project.write(".env", "UNRELATED=keep-me\n"); - let saved = sources::resolve_with(None, &file, false, || { - Ok(Some(( - Secret::parse("saved-key".into())?, - "system credential store".into(), - ))) - }) - .unwrap(); - assert_eq!(saved.key.expose(), "saved-key"); assert!( - sources::resolve_with(None, &file, true, || panic!( - "explicit missing key must fail" - )) - .is_err() + located(&[], selected).is_err(), + "a selected file must hold a key" ); assert_eq!( - std::fs::read_to_string(file).unwrap(), + std::fs::read_to_string(&path).unwrap(), "UNRELATED=keep-me\n" ); } +#[test] +fn a_key_issued_by_another_provider_is_refused_without_being_shown() { + let project = crate::tests::Project::new(); + let path = project.0.join("absent.env"); + let file = CredentialFile { + path: &path, + explicit: false, + }; + for (provider, value, variable) in [ + (Provider::Typesafe, "sk-or-v1-private", "OPENROUTER_API_KEY"), + (Provider::Openrouter, "vck_private", "AI_GATEWAY_API_KEY"), + ] { + let error = located(&[(provider, value)], file) + .err() + .unwrap() + .to_string(); + assert!(error.contains(variable), "{error}"); + assert!(!error.contains("private"), "{error}"); + } + let (provider, _, _) = found(located(&[(Provider::Vercel, "vck_ok")], file)); + assert_eq!(provider, Provider::Vercel); +} + +#[test] +fn the_saved_key_must_be_of_the_provider_recorded_beside_it() { + let project = crate::tests::Project::new(); + project.write(".env", "AI_GATEWAY_API_KEY=vck_app\n"); + let path = project.0.join(".env"); + let file = CredentialFile { + path: &path, + explicit: false, + }; + let openrouter = || { + Ok(Some(SavedKey { + provider: Provider::Openrouter, + key: key("sk-or-saved"), + description: "system credential store".into(), + })) + }; + let credential = sources::saved(Provider::Openrouter, file, openrouter).unwrap(); + assert_eq!(credential.key.expose(), "sk-or-saved"); + let error = sources::saved(Provider::Typesafe, file, openrouter) + .err() + .unwrap() + .to_string(); + assert!(error.contains("run jevgate auth login again"), "{error}"); + let missing = sources::saved(Provider::Typesafe, file, || Ok(None)) + .err() + .unwrap() + .to_string(); + assert!(missing.starts_with("No API key configured"), "{missing}"); + assert!( + missing.contains("AI_GATEWAY_API_KEY is read only with --env-file .env"), + "{missing}" + ); +} + #[test] fn invalid_or_duplicate_keys_never_fall_through_or_appear_in_errors() { let project = crate::tests::Project::new(); @@ -160,23 +316,36 @@ fn invalid_or_duplicate_keys_never_fall_through_or_appear_in_errors() { ".env", "TYPESAFE_API_KEY=private-key\nTYPESAFE_API_KEY=another-private-key\n", ); - let error = sources::key_from_file(&project.0.join(".env")) - .err() - .unwrap() - .to_string(); + let path = project.0.join(".env"); + let file = CredentialFile { + path: &path, + explicit: false, + }; + let error = located(&[], file).err().unwrap().to_string(); assert!(!error.contains("private-key")); for value in ["", "private\nkey", "private key", "private\u{7f}key"] { assert!(Secret::parse(value.into()).is_err()); } - assert!( - sources::resolve_with( - Some("bad key".into()), - &project.0.join(".env"), - false, - || panic!("invalid override must not fall through") - ) - .is_err() + let error = located(&[(Provider::Typesafe, "bad key")], file) + .err() + .unwrap() + .to_string(); + assert!(!error.contains("bad key")); +} + +#[test] +fn credential_parser_does_not_execute_shell() { + let project = crate::tests::Project::new(); + project.write( + ".env", + "export TYPESAFE_API_KEY='literal$(do-not-execute)'\n", ); + let path = project.0.join(".env"); + let file = CredentialFile { + path: &path, + explicit: false, + }; + assert_eq!(found(located(&[], file)).1, "literal$(do-not-execute)"); } #[test] @@ -189,25 +358,47 @@ fn stdin_supports_one_key_with_a_trailing_newline_and_rejects_unbounded_input() assert!(secret::read_stdin(&vec![b'x'; secret::MAX_KEY_BYTES + 1][..]).is_err()); } +#[test] +fn login_asks_for_the_kind_of_key_by_number_or_name() { + let ask = |answers: &str| { + let mut output = Vec::new(); + let chosen = ask_provider(&mut answers.as_bytes(), &mut output); + (chosen.ok(), String::from_utf8(output).unwrap()) + }; + let (chosen, prompt) = ask("\n"); + assert_eq!(chosen, Some(Provider::Typesafe)); + assert_eq!( + prompt, + "Key kind: 1 TypeSafe, 2 OpenRouter, 3 Vercel AI Gateway [1]: " + ); + assert_eq!(ask("2\n").0, Some(Provider::Openrouter)); + assert_eq!(ask(" Vercel \n").0, Some(Provider::Vercel)); + let (chosen, prompt) = ask("4\nopenrouter\n"); + assert_eq!(chosen, Some(Provider::Openrouter)); + assert!(prompt.contains("Answer 1, 2 or 3")); + assert_eq!(ask("0\nx\ny\n").0, None, "three wrong answers"); + assert_eq!(ask("").0, None, "no answer"); +} + #[cfg(unix)] #[test] fn fallback_rejects_symlinks_hardlinks_and_broad_permissions() { use std::os::unix::fs::{PermissionsExt, symlink}; let project = crate::tests::Project::new(); let saved = store(&project.0, StorageMode::File); - let key = Secret::parse("test-key".into()).unwrap(); - saved.save(&key).unwrap(); + let key = key("test-key"); + saved.save(Provider::Typesafe, &key).unwrap(); let another = project.0.join("linked-secret"); std::fs::hard_link(&saved.path, &another).unwrap(); assert!(saved.get().is_err()); - assert!(saved.save(&key).is_err()); + assert!(saved.save(Provider::Typesafe, &key).is_err()); std::fs::remove_file(another).unwrap(); std::fs::set_permissions(&saved.path, std::fs::Permissions::from_mode(0o644)).unwrap(); assert!(saved.get().is_err()); std::fs::remove_file(&saved.path).unwrap(); project.write("external", "do-not-touch"); symlink(project.0.join("external"), &saved.path).unwrap(); - assert!(saved.save(&key).is_err()); + assert!(saved.save(Provider::Typesafe, &key).is_err()); assert!(saved.remove().is_err()); assert_eq!( std::fs::read_to_string(project.0.join("external")).unwrap(), @@ -217,39 +408,59 @@ fn fallback_rejects_symlinks_hardlinks_and_broad_permissions() { #[test] fn authentication_is_known_only_after_a_checked_connection() { - assert_eq!(provider::authenticated(&Ok(()), false), None); - assert_eq!(provider::authenticated(&Ok(()), true), Some(true)); + assert_eq!(verify::authenticated(&Ok(()), false), None); + assert_eq!(verify::authenticated(&Ok(()), true), Some(true)); assert_eq!( - provider::authenticated(&provider::http_error(403), true), + verify::authenticated(&verify::http_error(&TYPESAFE, 403), true), Some(false) ); assert_eq!( - provider::authenticated(&provider::http_error(429), true), + verify::authenticated(&verify::http_error(&TYPESAFE, 429), true), None ); } #[test] -fn model_listing_errors_never_echo_provider_text() { - assert!( - provider::validate_models(&serde_json::json!({"models":[{"name":"jev-latest"}]})).is_ok() - ); - let error = provider::validate_models(&serde_json::json!({"error":"secret-do-not-echo"})) +fn each_key_check_answer_is_validated_without_echoing_provider_text() { + use crate::provider::KeyAnswer; + for (service, valid) in [ + ( + &TYPESAFE, + serde_json::json!({"models": [{"name": "jev-latest"}]}), + ), + ( + &OPENROUTER, + serde_json::json!({"data": {"label": "k", "limit": null}}), + ), + ( + &VERCEL, + serde_json::json!({"balance": "95.50", "total_used": "4.50"}), + ), + ] { + let answer = service.key_check.answer; + assert!(verify::valid_answer(service, answer, &valid).is_ok()); + let error = verify::valid_answer( + service, + answer, + &serde_json::json!({"error": "secret-do-not-echo"}), + ) .unwrap_err() .to_string(); - assert!(!error.contains("secret-do-not-echo")); + assert!(!error.contains("secret-do-not-echo")); + assert!(error.starts_with(service.label)); + } + assert_eq!(OPENROUTER.key_check.answer, KeyAnswer::Key); } #[test] fn http_errors_explain_rejection_and_retry() { + let rejected = verify::http_error(&OPENROUTER, 401) + .unwrap_err() + .to_string(); + assert!(rejected.starts_with("OpenRouter rejected this API key")); + assert!(rejected.contains(OPENROUTER.keys_page)); assert!( - provider::http_error(403) - .unwrap_err() - .to_string() - .contains("rejected") - ); - assert!( - provider::http_error(429) + verify::http_error(&TYPESAFE, 429) .unwrap_err() .to_string() .contains("not retried") diff --git a/src/auth/verify.rs b/src/auth/verify.rs new file mode 100644 index 0000000..caaa303 --- /dev/null +++ b/src/auth/verify.rs @@ -0,0 +1,111 @@ +//! Checking a key with its provider: one free request that sends no source, +//! whose answer says whether the key is valid. +use super::secret::Secret; +use crate::provider::{Endpoint, KeyAnswer, Service}; +use anyhow::{Result, bail, ensure}; +use serde_json::Value; +use std::time::Duration; + +/// How long a key check may take. +const CHECK_TIMEOUT: Duration = Duration::from_secs(15); +/// The largest key-check answer read. +const MAX_ANSWER_BYTES: u64 = 1_048_576; + +pub trait Verifier { + fn verify(&self, key: &Secret) -> Result<()>; +} + +#[derive(Debug)] +pub struct RejectedKey { + status: u16, + service: &'static Service, +} +impl std::fmt::Display for RejectedKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{} rejected this API key (HTTP {}); create a key at {} and run jevgate auth login", + self.service.label, self.status, self.service.keys_page + ) + } +} +impl std::error::Error for RejectedKey {} + +pub fn authenticated(result: &Result<()>, checked: bool) -> Option { + if !checked { + return None; + } + match result { + Ok(()) => Some(true), + Err(error) if error.is::() => Some(false), + Err(_) => None, + } +} + +impl Verifier for Endpoint { + fn verify(&self, key: &Secret) -> Result<()> { + let label = self.service.label; + let (url, answer) = self.key_check(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(CHECK_TIMEOUT)) + .max_redirects(0) + .build() + .into(); + let response = agent + .get(&url) + .header("Authorization", format!("Bearer {}", key.expose())) + .call(); + let mut response = match response { + Ok(response) => response, + Err(ureq::Error::StatusCode(code)) => return http_error(self.service, code), + Err(_) => bail!( + "Could not reach {label} or the request timed out; check the connection and retry. The credential was not verified" + ), + }; + let body: Value = response + .body_mut() + .with_config() + .limit(MAX_ANSWER_BYTES) + .read_json() + .map_err(|_| { + anyhow::anyhow!( + "{label} returned invalid or oversized JSON; credential was not verified" + ) + })?; + valid_answer(self.service, answer, &body) + } +} + +pub fn http_error(service: &'static Service, code: u16) -> Result<()> { + match code { + 401 | 403 => Err(RejectedKey { + status: code, + service, + } + .into()), + _ => bail!( + "{} returned HTTP {code}; credential was not verified and the request was not retried", + service.label + ), + } +} + +/// Whether a key check's answer has the shape a valid key gets; its text is +/// never echoed. +pub fn valid_answer(service: &Service, answer: KeyAnswer, body: &Value) -> Result<()> { + let valid = match answer { + KeyAnswer::Models => body["models"].as_array().is_some_and(|models| { + models + .iter() + .all(|model| model["name"].as_str().is_some_and(|name| !name.is_empty())) + }), + KeyAnswer::Key => body["data"].is_object(), + KeyAnswer::Credits => body["balance"].is_string() || body["balance"].is_number(), + }; + ensure!( + valid, + "{} returned an unexpected answer; credential was not verified", + service.label + ); + Ok(()) +} diff --git a/src/check.rs b/src/check.rs index 11a00db..08960c9 100644 --- a/src/check.rs +++ b/src/check.rs @@ -30,7 +30,7 @@ fn validate(args: &CheckArgs) -> Result<()> { } /// The credential file: `--env-file` from the invocation directory, else the root `.env`. -fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { +pub(crate) fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { args.env_file .as_ref() .map(|p| context.input_path(p)) @@ -81,8 +81,11 @@ pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { return Ok(0); } let store = store.unwrap(); - let mut client = - transport::Client::new(&credential_path(args, context), args.env_file.is_some()); + let mut client = transport::Client::new( + &credential_path(args, context), + args.env_file.is_some(), + args.provider, + )?; let mut session = evaluate::Session { args, context, diff --git a/src/command.rs b/src/command.rs index 9f14700..57a5437 100644 --- a/src/command.rs +++ b/src/command.rs @@ -51,6 +51,10 @@ fn configured(command: JevCommand) -> Result { } JevCommand::Check(mut args) => { context.configure(&mut args)?; + args.provider = crate::auth::sources::planned_provider( + &crate::check::credential_path(&args, &context), + args.env_file.is_some(), + ); if let Some(base) = &args.base { args.base = Some(revision::resolve(&context.root, base)?); } diff --git a/src/config.rs b/src/config.rs index 27290df..3a30bdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,7 +35,7 @@ pub struct Config { pub max_context_bytes: Option, /// The level for rules without their own, like `--fail-on`. Default: ["mature"], which fails only on the levels of a rule measured right at least 80% of the time on projects JevGate was never tuned on; `jevgate rules` shows them. pub fail_on: Vec, - /// TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it. + /// Model, as the key's provider names it; a pinned version keeps results repeatable. `--model` overrides it. Default: `jev-1.13.0` with a TypeSafe key, `typesafe/jev-1.13` with an OpenRouter key, `typesafe-ai/jev` with a Vercel AI Gateway key. pub model: Option, /// Cache lifetime in seconds for an alias, a model name without an x.y.z version such as `jev-latest`; pinned versions never expire. Default: 3600. pub cache_ttl_secs: Option, diff --git a/src/evaluate.rs b/src/evaluate.rs index 957f087..0929708 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -100,6 +100,7 @@ fn empty_report(args: &CheckArgs, current: &SnapshotContext<'_>, files: Vec

3iVCxuGN~GE81uaUEBj zA3qM@OYp!!C&D-T-1-YgkPJKUe!2!|QF|X&G^|DcOpq6iqp#R5))~A*oWpCV=-c~q z;mU&0?h4z13-8@KYRIYq}~2aBW5 zdssb&m7gnGp>{Hv6QKm!3`+AYDbRj@1^2s8E<*`G?9@~CALyk0&eqnAfQTMAmvCoQ z-esmJsiQ#hco$nZ8-QTGk*ITCu-+mBPn4Uu-a@N$_7$3z{y1SAN(BmD_bcEg&Zy~m zW@$VOq@TUxhdW2PA%I-$Zp4{#s08FzQG=dpI|Z8!4Jv>G^~?q}2NgTpOa=_gdl?8) zXB|$b(yaD(Ml;U(-y1IxVtx#Q<$jZ(~j4{QVkv&Bt#nda~d%vJ!9ZTEq>)6tEtbr z@kBm-=2LXN8cd+XvSlH)iq$(~+EJjnftG4%F`@V-EGB|REHZa-(fNF}x?gJKI9+|l zuasVOJg_-jeGLKgO;AmpQ?_Vtdv1aXJe431i|RL3;j{hu3rEL2>V+@=u)NvpL{FvtA=|9}7!} zlx!4&DgoPm8BcrbMq42MT^26kf5Sd^lYx})KBwyD?tSZ{q5IB7UvGK(G96Fi z*V3#+|4Faoo=n{M>Tn`#_p*EK?M18s^LHL&93nO+m_g!IR7kvk1c;E~+5V=R>R_rb zeKI(v!LW{G5Qa%LU|6YzQ=DT>okcB%5i2{Sm$NFzdXCwk-)qB?AL9il44j&#F{#we z+o5&$di$Fn!jAIK99s1_1g>Vg&7QpcN%65{pUTrUoW9Vz!-f;?&s6V6cJ3OO$q|zJ zUekUd(K$GC_ErVMK5?>~aZePm?DZT1jtJ)-bENMTxi&i&Z;C|g3$#ZFKvcuI8o?c& zvLUMTEr;aw2Rt!IFzVn}`$_jgem3%O_7=upm1!6QDe(-twdF!IyADPPK2tGbJ(_3# zs|t#$8cxdNjEI@UnlCMb6qUBc69iMJ34OEcvHL7ix_{7cZJ`__MveooV5$BGm%h8x z?IsHUk6>Jh)P7wLu%`z&5DV`m@GX*1Ak-P;BWxzOKBBNYssT%<<>aRpWI(l34O@Yf zl+0|Zt0HCS_Xcg#u^62F5P$^0YnIE2LK+=~+#ay{@PM75|2F7%UIh2luu~B|V_Ie`E7KW#+U$@^gR-#KOG`9L0RCY*{AkB(l9n%D z=VWE+XN0i4$hAGSiLP~H+f#hs@vrC{U6~9sVT3?PgU$Wry*esHzm(yu5#SW;pTto4 zNG-1c(){6d-j1Q`$xG<*FG5?-@=C=&gfN`vR*ye?m&KVqXnAxfps0p zYu9Q4Wbmu*Zz^Qi`@SJaTI4A}4}RSf4#QTY)93`dM7sfOga5-h7~@F?tlJ?Qf6!2^ z1ul{7eyRxxCt}yc;VPZ4&~}4%$N~YD3G$XFqo*=F4+5)a>U{Uv+fXipkJ(z znmJgubhQWKEYe=DiAdTLw)R$Ro;qAZz(bvCMDwXX857638`8eny?CHC?znGuj zAQ(-p{u+@!oEUu%W(2*|zJ=YwP zcNM89F~z~&V{fVU*98C9Pm(@2e>CA6|GfpF#RBRuo}+$73|FVE0f9mGi6y-ogBjC` zDj*ioADXs5b%zGfa)@dKF-LH{q7>ZLKUO@s03D>v-@7?Jks;=y`Ws0wiW0Fi<#_kw zH!4Y(mHL7A40g;;{u+9D_ZlIr64yEQDtg3bn1CXt4e>d>qm<+xZ*LXvw9>1LyK4Ne;(29JjVy2Tl8% z4pP*3cw(ii)1FhR%f&am5SBc6pput9NHV7-0IT?7T#BAlFHWs8{Zl*vt#^LI#XeT(Ty`;=^k7$%4;rM~1%Ox6{xZ5gHS&)q*Ki9ym#+uWhJyXLnY^i+ z=6m&r_mEDPA28nvt&_d+G7>QO71VI|Wel13O#Dv2gFH`3gUaGJyC(Y^Ye75Nd+ud# zL|p7b)u&7J1O;RXx1ZemNPk|%ejtcZU*vzcpjKJQud2bm0gzc~VpixwcY*OPe&d{W zY|*$|Q+8`f_}*k~>TnLk#oeD2x3N^dTn2y^g{9hkLLIb>zoMnlAJ9;mcyry`HzIi= zZAUud4zj8!coPs%wDi92Jeoxgfs`>sAYhR1`|J>55_A2)C#X@2dO`Irz_f+G2g5&E zcvPwSJi>{ZSj{!`$zML3+h*L#rRP4^!xra}l6oJ?9qor#d3lZ$z0Brza$aBmQWq8f zgZKFL{(9kg=%k)nKq;Z2%aNk^I}J;me<#snkmicWz8>7_l3clZV`L>UC4t=uoL@+> z7IP*!=@{+U3WW_cor{J1TJN57&Gb3Xo%->v&ErH!E~7n9oh8Sdo(=7pEE{}_@m~#F z|0nW|^%kuR^Aihr z@at+6!e=*tkmX+>5&(%m!sQ(pj^K2@*8ia0-0kp5^}{BgMiu!-9MOLAbYj}t4jGQ8 zA0Is*Uyp_w$)Rpl0vp=53t7>$IxE~}R}SQjkr%o%lCfX;!R7NWe~`5YR2o1oybqNB^#HnmA7Jh7h%k1xQLma_NN$WAMA-s0Yl~>|KH;X0K1u2= zt%4>g`1rh^TLtPl|3A>XZ^Orz9 ziLopYrKU3F4+!<}{|SZv7j<67T<~Ai`PPzJ#!>Bub&ToJj zX(Du*&9TuId4eI^*zU(qO*XK>>NFBha~J=*Zynk@Ub!&zk5xSDItat{+7aWzy{^ zcuSpTHXjiiM{Puu3YqA&0=c<*=ADVOKm79ok<0#h-+mfk*pN_wuC;)(qNbY}A{NK~ z8N7KT*dIdymMtS#u%IIM8JITTw@u#2{5$`x;h0|nn7*~<08iWq%_sf#dt%%w9N;VE zR|W1os>J1=hK23^i?Ma?==^>+I5Qa%TD!khu?^x?XHIQx0NxZnV7xGy^9DGR1OfPQ zyyMD&*7_)ERe<6ZcG$mV6>D5Tjll9>3V;uwD{!`}`wc3YF!s@sO8{b2N=dt_BcLne z_d3r54BF1t_?(CK;y9Evi9Qt(7h;j|dnXLUGxQ6Ky%3RyS#aiaM{eq}m8l=sbK-b(G9+gFwP>m!MeqSt@=IkE&2A|JI>!MEBin$8nw69}w!B z1ub1*7F2jb<)RcokUt2?yLmo}0>EruD&KNdxi&IQnA>ZVJ@4g+YJ@v5`hJ;10` z`*+h<1uF54QO~>k_}_9`62vY?xs`gIisP`&{q@FhP*;X#=S@xy2dFqSWjY_28#LCA z6y5Y0yBP`P0ifdI=pZ;m3$1X}GN8=PZx;OS1H1s#20r;wX&2t&`PjAK1W=ZEjKDHp z@j1}G8N{<4V$RDqt zmLJeWFTakaMcunt$&BZS?R4g5$o&tACJ6W$X33Tl5frSfv^ZoEH9#Q%VYqv7@X0b~ z=K6ehKuW~Dk&DlUFJy@1lM_F5&Lcp#&0Qw&d?yPSz;P}T0FZ!Z?5`%pt~N)3+$_i) zgH+n#H|Bb8pB6ZbYxu#TSic?@#56 zHK~j95eW2m?H%bl01zI-uD=Cxzdu629r_U&biH2$(M_qR@nwHuQQEQVd4C@c@TjrU zyD*vohOHSQcRZXGI*629ggd@$b6@LGFb)Pm; z{pSX_3{95zyuBsVX|vbyXy0A4(`++;Qz$HsOqS4a%qsy*ANZ0S`_90V)N_*q@uhS( zDj#sxc|LW2$PnS3vr(<&8T12CoLWbrfG+^=PwRF)(N+myDH<(;h7bTsN%&0GT~jpX9>cP36g|`~|YY(~^xIfaH6=GtBxZua6it~p&HYGVjj`e$xO;=if zI!gy8gjX-Y8x=BU)fInt(9qB52drtU$9g%11CV6_a;I=3(Eftuz>m31xsnl$6L|{Y zVaDs+2Dz=;%$CjL-Wl)`u{tAC%MwK6eI^}%ooWuHZ@;;f_<){vyCD1_fmYS`Ftp{X z*et;7fPuXum0**L=$H*i`pt7R3`!q|8lv6xosV}vXy+r)bB<+v>ob+8y!Moh&gA7< z&U|*?2gt`(H~cGmD_H<~enf4?wes*ku3oes(;) z)TBBoIF6Xx3?sZX?hlSqfn)LKQ7z0UG$_gys)K-RYHJ!Ga}XQ+8uf)jqf-SFnx-HI zaP>KU%Zo99s`!yS%M+?v=d!Hres{nzi#gT!p`3Vpa+k?$AT8hXwN=W@%ynT>yD5hr zoP^RYUH)B~2CsOisLmpL0VXNgpJ6YY#+QW1Gz~mp<@T?JIW3+xD6Eoe&%s8#EuP3CAz7*u3>W+9>$#f!h5=0&9`pIv zp*i}eDv7sa%UZBSm!imcqBRCt(a=(`D1+cc!-GCgixW%dh(=Ap`)eSy6T&^LE3l$3 zs0IB2NRFTzhv}Vm5h-VYZSk^P z095qH09#Yn6~4G%z(9GkUk&Yb#{0^sxODG)@W;ckYO$&t0+RBxgzMB3D5o`gR4Ui_ zI&;5sHv~}PWeDZr17nasa#+s>526^yR_o&uu@$A_=@i57|4AS8Q9l(yfk?BcG%1P< z#BkycD*J{s#J<@*ZHs(}PRk3javzV1U-^zL*zb67II{S1a5a6&GcV=VpyD0UP}|_; zxiZr%lD1BCtjm`AVr}6A{K@OcBF*>&UYmVQT>@m|z&%j_88n+s(THQ#eH*3UbJc6e zY_5kYogIP@cu70@b+^3oMJfjKa8g054e2}4EUrQe;OCzQupZ-Wp(3sdDTXSk@>?O; zP5i4foSk^0u~vyty2P1lB{3vGP@gAr5?8jZV6FRT6uBcI>@`?@B; z4&zsVv(JMIYLIXXTpCQ`#C;*AjRM-k#>LD4_rn-CApuVDgFgN!efy$?!s>ge5Hg?| zX>l&Nx?KK7oC=28k{q*2UhK!U7g#q&S?8E8#sG=zFs9%UC<}+nm~TZw$T@%e&_e{{Meey643RiJ zS1UWfmiu@Q+OLZ#NB|pr;a1ZFW^JjfQW|0l-qxLP$5oK64vmLjBvf2bz*FR2#Fv*^ ztse0_mC3`;G(=B1Y3&}b3S$@d0!G|<7xhRWeRDBb#u_8YzirVgSNpvOQB=BP5Dmuq8C$f-1JpoKh7#oYXm>ONz`3!3Kmu93 z*KfO4Oljxx<+O8nk5ivrT)M<<^O77LG#(!kK&Mbn`{bdI?gSM`+5Osj&WBUx@lf#g z5Axls_2RjP2dw%dR^Jh*mW;`W-m{QAg}K#28?FFyNn+)SJV~PUBY$2PEOvUNxe5zz zSYov0*iM;fbKV~Ao6#FLY$a4^5*bT3@#2fF^Jz9=(HJR{pMX*7g&o9rgH-irZ zLjjaKbv?WRQvEO?Y@!KtW?!7s0m}}?K}_|<=fcrUtBh7Y;qiqlRsj03?UQ>M{EoR8 z{7U>w`FE?oc?VDhwNK&Lc@?mx#ox_SZX4$7@=iDV0m@3aB7LF1ST z7W)FMS}O0KP%$rj@gAdKD6n+AO;V((Q&8$7WfGv)3zMjS55gWV8JJCwuba+@p^WCS z(O9(x?BF1!>_Dfy)=es6yY;|?9g#2K5zo0+YQiI}s1$%H5$)-DEKZNxvGOtonf>~Vj%nI=s_!x`> zI8vOB`o8~R#Tmd6Rr|`Qn7#|^d49xQ2r}|R>1WU5$2^!%Q1WM4UGmE8+6bx~qMBNv}e7FWSz|D8K7v0BT&N4coTc zbXqk4bW3^5LEl_-^+sUFD;cRQeC%pAsOTaX&%KrKKoa}e515~THjEY%PTO1s^R>xV zY9zF#elIxr%L9@v4xd$It!)!k%&M%&a)((oh}HueP%zG;vqJ!6fWJ#6a?9is?b@VB zOB}E5MBL?*4e##w0y(mq9Hu{Km`of2FNzF@&4Y!8?CN0puraP&9X*A)j0QMm_h?mR32__Bazqp5dKGFn}+pILInpVm24ok@VELwM_7^A|rV8aJ%U0Ds18WO90- z6EfugW))7o4JP(mjAeakeD0?kGKE?UFO4<|%3-{a&-NACDflkch&>4KVwoc!lqp|*$a0r<9H0iyU4DpGtfbfkR$wUa zXc4u0_U+a@+Q8p#lPabC@nB+Q!EQMcEQvy6ks4r6lY7R7Hx9HZxy$kC9ZCa(Y|9eH zyPHO09Aliiio9~743myZl=%Q?h)^1t8Z*qDsaNEVkCNl1wW^=qC_UQ_~Hix zCxNL}h-nW9OI_yv_?!4tarCDXve;BE>t$mD49U_cwSSBKpYi_taugnC%~#6q@g-#_ zf(`X^TPIIs2&DWX^tZ0O75B`c___L?VM3E-ug|j6gqiL!qJEle!HH zdbJq9+vN-sg7^foGuHV+xdrETl)boyuot%)s3kJe?`z))vE#y{?EHqq68m&b znmu3~UL!vam%Rs+CkFXLG+&+Z)cBZK^?nhp6v`1SdG!`fQJtkRi?pmntJpe~EWj|k zZN6S54``hEJ|1wJzD1*#FBAOGp&B#Rb;PsY9%SWFJ-0vc+XLYX-`8!Vp$417rRsxQ zPqcM}q3gmWWBKXSy4?+7Uhy2zHX>DZ$JLVB*r2M!A)}jit)2Lp}ci&*~_cdNTQ7ffqK;Z z3_|d8hmAJe%hP)&K)Dsj4y3Y0p1{Fne#qQIrG@ytsE^CPHYWkv@ByEUmtXO8t=n57 zS2an8hC$h%UB$xbSH?NpVu#J>a>kR!uff4^-S+BdojS}A*{8P9YhQ?7mCT?6`zIT| z+m6Crd;l=1yI#o*)9`@3^=Ps+D9hk_M@Q)t315mmrwiDgaF@*D%D2x6%EC1uW0sKSmzn28EXGw0RnRV>jVE^{}4)q@Y!bJ zsN?-=X&k>3D=HahgeuJHxr9J0LtY z+u69f1Y-HW$N>T->F0;V%%2@pCVK;spQh@Z5rWBZ4sMDa)b?iDgX7p0cn30AI1teA zlowBCD|H>OH=J7R9{9Hhz6as-MCL!q$epZD7G*1OOQnD-t~0P56089a3?s$r$N9RO zqiwZ>;mWn?RF^5ADF$=hMjU`=I#oNGcn=^H*}wxS0vv7K&0|<%B0iA+Mjrx6#X7Cb zz&q!9C5kmR<7XnNL^_o1!C02H%!vQ_%}D-JUQ__Dc%26DT#`r=PQEK#At5q1hAH8I zN~UX@D^xI;)!&q{m>y}qf&IeQzORaC@=QX3Drrw8ia)VvxFjjz>jWF<%j( z&Pv_?dMwwH>|HA3XqZu6j_+vz6$ zBsP*)QGN(ma46K@_~4d%MC3hkvwc_UHS9D`{GTe(e?o-!%xI9Y8gE>>?@Wg`6Q}9& zmSf)JDj#%W=n{r1+jq&1cy0XwZKCvG0mj%0+UHwUFy}sdoD}{DL}d_fV&!2uBBdo@ zrQ<=+ONaO@`;pG9!T1I{rPY#6hHxgC67pJZmuZ>2QK8mU0ux^<_8q^~s!Q5mts|hg z$pXZXlLx~Ore`Tob~6bYJW+R1@U!k?Y4jOW_8fq>w>Z48L>|m01hVJoDEQ1+&i9RX zH&AN8d8}U7@_L+wX*xO;VtZ*SZNS^r0pIgqt~`KEkL7>Xhz7Xeg@#b4$^FNB4Cj1; zaY_d_b<|!Uq^|Mgc0Yi1bN9}7e0i788Cj~-b84VrNFhaI3Uo&}?BP>b%LZ?7uD;#{ zEjvCAK}f&QBSvJ%pltT$+;a?!-3@Yml)1ca7k?zBY>n|pr(n1E19$|X5Wfdam#U_O z;r3Tp!Rf#9xfl^SzkK|;D3mAFS|H2r@dvTxepN?y*4au|DLksCfyoziLKr>^Xzy*n zh5@$9g;J6-RiuEY!4AV>9)zlSs`NAAWI>h)&HqQ-TL;CpJ#C|h5G1&}OBmdOTW|s- zxVuAe2yP+RFgU?AI0Sch4H_i4ySoo^cg{~vzVChiymfC?YAVI-nb~XYUaNa`_wziX z7wqBWvoRtF7-w2}@|2PKqMz_47-Q5U5nqFqZ2TTdaCMhssDld5doPsk);;c;WyVQ) zocYpuy^;Y2f)Atdkh3#}EU>DP#5~~-48%kLW`E27tz|oW&&k%Ck~8r)a_aVC z;hHSxH~Gt(8@rYjp^y*b^QBO$8XbY+g}-=jp6x`wMP&+)=ULbPXmw+hjXiy)GMlQQ z{j_`Q@x&8p<=}=t@3)CUD(D-j5*?M+)N%3~r3u--_<$)jb$F{axyV=MG%U(1TOGlG)H)cNXa|4#B^_2sZ{R(HGq>&PxV>lm2!CHH2LV@IE z{!G1njbSfd8+WEqyJD4uW05$gMYrnG4y(arpcbP?Do7#M=sSms?MuvLnGs2?UnQz( zRb~&;&^EDO$P!lTnr|}>_wI~fm3t$~+yFFv$u%nILW&e#X(ciOpy+YW-$A1cIa+-X z@jcJ2q5rDboX^D@KU)|jN=znTLMSJc=Dal#ROn>2v8yb1DdxBlDPH5c;OlvHPYUci zl(p8%0RY^nXT0qlR^OU6!cT6UPoy7giPQ?*9gQm#_vgxlfri0nu$pf5t7>({Ce?S1 z!kWcHz$u99aAA0`AFJk<7kY+bqPoj1*ZdsxhDv%N=5u?g_h!XQS_c~AH<`R%p}|)+cRq3wKGMUvo1q(ysrBmDpab*t5ascQ zIjfzgjW{MH?<4=09loAu>4TZbn1Z}Okv}1-K<{MyO|}B@5lKF)ZogT0oR`i~hx*(G zROg0d%ZVwDjuS{ZPks!{_N$ft*P%q@)xRAru;cVr!p=Kiss}n4@vT$KL{k7bjikLH zcf;Abcc@6j{5bEE>r=kGMD3zFTJA2Gvi(Uz_BN3z?(2H*HD3sUvaG;OW``3}038?x z@kRZI2!PT~5CkUvwr8J;rVFfuUcIqZu z-DIiMc+423a5d4Pzo0z$sLt}iquSz|i_XoYzlnv_=Myrk8$T+96V>qbF+;?0c+?Nl+qQuzY1ba zGUQQ4DH)sVi=?$UbJrh%)`>KP+vz(04Ye(UI!&@Nx@a^EI;1pc&m7}kYY0NEon}Oo z)!n^bjngC6}W*K__CehUSi98upJJq^2?7?j%@Mtu?y0k45kiEezBFRQ#pZD z6ZxgTaAn?*h`dye=YXU4%762~GaSL2@8<=<=1j5j&_x4foTLNVa=s7N~e`DUqVHd`W?h@gU5p6IueYD9(O__F8=p!arTc{HXxV*82 zFL6O>98JqA+ohHBJnle|luPe^;xo&8db5c@V2Sd%Z*Ic1O#_r2hJ-lWHkQ5^MpOOY zSXQ5TSrC~eIC3&S|K#Xa24$1x*juX?Q2E(kPei22A{&Luh|AZr`FWEcqu-DSk7T2j zQz}qCCCcw9bOvZ;7EEdDjYjj|q5BZu3sj$1zVzt;{|K}EVajt4q+HF1+L%TiY-Z6$ zD`TAbd~ewu4s}0jwQ~SVF@2k5L1GLjs9JS5&+D~+)@tTF&{*;E-fT0FgvqhDn>Hgr z-JJ*rcs0yw4=Irx(W0*kL(bNFa@{L)tos8z;j(xPhe=p6<5qTd0#79ulrkj=Mbp-^ z{Ivw$5_2$m;f{p2(<)$Pwj&d2w(72t1M9Hve_4l;xgMTC@vqEoLn{pc+R9)<_&5tF z3-Em04GXd>ely>s-z$9kAuix7kyH9f0_74T&!2C9+FnG4k>@ynF2`#kg&FGcmg`q* z@wL_f5n_XKkn@~8a?dKt3%f2u4d2#JM`-gcP%t-d_l*UUu0j}Hv!iVBT-#sSQi8(1 z`2Z@zgaQnEry zW8Bua86Qa>Lzp1g3f(PFK>dDdK|!5ZGS@jX6Yj_Q+<#FjT2(kuLp0*0Qaz9BEkRA? z2?6qDg_3eW7G25(PDv|T21Aj70a+|Zv>Ap(G?CucY4W@V2hxm|Vd@VT8n7!Rr)2$V zllGd=#T`HL_yq{RhCPnGY&_ICQHu)Zmn$Xj6*3s~BX?6ov(qg4t1p5Jc^OE6gMGWm%zm=Iu9r6PTS#(^W~a~<1+ zmLjon7ELK{X{W&JDp>M%=`+}Fr3T)hpwuhsbUC5M<M zN1bp~{MNOH)*zspTj*cswug8B4dnDdRPRxg_>k4@@?v}zDs%%C<&$Y(q~qU4Q`lk_ zUINT>`^t4F&kt;~GXXHT5>(1Im3k89Z&|3W2xG`(%v23e!*;ot*0abz9dxduz;e`D zB2%r@oe1UU>79spCQQzr`c(U@Y38$NihH4FuH8gSg@BTlkBA-KkWZAy@E4luA2HB+ zMB79uN=%`_XZ%&v1AaL{4qL}psN;;6_%TxCFU*;WZi?m|Md0b0L&fe^y(vsE={bRZ zZoE*LbO|jn846>JqJ8s)pNZ|5QesN)IN?mw84lhHpijouF7VZ2KzpAkoPpBkqqqlE zsmEnap85g8TZsD&rAnc0rJ(jb!)NceNDSFpxV$H6S`+utqDZtjoVg#&wKXWPDO@P# zaFXm<)x!(Z7(S|JB$x%r6cC0@kB`FA-EkITzqi(SFtQWY-FfG%0R_S4&YEC;4Z?;x zs-|plebE&A^xe!$9h8N*_L3f{!hK-X@j{bqM89ghzfQF$xwZ{vr#NtZqky#}1G;L* z>@JYtVi469)#!eO_ddNn=Zf+s#L1I~&j==v4 z8k(D%y<*n&7sHyH$2$I^Zn-*HTQ(PaO^qQ-2GuDp9)9n%HSm7a?MApjf5AVWhQp8s zD~F=dU~pVzr+hHYDxoMU+}m0;r{;auK#j4~HXE?#T5EN|Fo_Hyf~1_WMmuMGPU9$E zNqY5Dh3D>ZyCVVR_AjSv-GhRk<#Oazvq>SV6rUm3S5>~zzt9GlJM7;~?95z9) z;+uQAH9@zun~~`@cf0Fj)Hugr#oUs!gZYR@%>*Ipe0s_VIG%^5{t8=m(&X%5`g@#5 z_?Apk5$AUks>!2r?}oVTB=W25*=-lV6m`MQ-c^1x#oEf-%%+6c`G)leLf6$BmFO3O zVGk+TW{~aLg@n=*+ovloYXUCM%PBZPzdP;iPpJ4TOz`2!%p5EqN_MHn=tlHtXU4cc z{OEfU>y*i|1ZzCc<5$j{94=}6Xuo-CA`tY=AZ)ZKKV+t6d&GuNd)A9h1Uk|xH#in# znM=@DhV$0hH77iN##+>$yU- zIPHMR@e3~&%)9VQHngVX{ed6XgtYyibjW7Fhlc!C`#7OyofvW@2o~3Y*{vo%x2u%S z*^X?oqWl(fuS0p)uY!?qk-A?qV_3!V>B-tGcu7#*CsjRe&dJSLj37yx!N5pya6Co@ znCghd#Ml6I8DjTS@5T$I2(@~X&5~Kvl#>m@kb@M}9US5G8Fx5xzp)2W_3r%c^{LXA zoXg|_kUH%qN_A6~;~LBTyE3Dw16Ah97v6k|*Y@PoTlUELn#T{&B#BH%vU)S?dcW5r zC|OA$Q4%XJrZK8J=fM{&X*X;jOh{B_Tl5X*;DD1pZ@#}WOC@vFdu3ywnLr(wxBZ$m zJzCsJB5_bttH*(b4sZtxL@JKSQXi5#8qaz04ZChD4-*RV6gMqgk41vXj`n|Wg{M~P z-hZoYVBZH{^_#SL9|b?yUJcTtJwmkEzF!1 z-bAAV`1o9JP{Zsr-+LG{2Dt3jIvXGRoRw+~;b{Wpui36=n{9~4>&Dj>8@`<$^n^mZ zEf&Zv&#R%78#_P5F_C&}{f8;T3O8AEs8g`2@Z#u){Kmon*_EV(j6MYCU49wi8EF$YnMaY@F$ z)T}XQye1K}TBc*Zn|K=F$a1KyJx%zR9s7m;+m7!rgRF^VfYd3#a}aZ zG|J6vxkxo%TI!628?R?$%M()9ZK4^~pd+lw!_}tMM`oJMvrUG+Qo-i~n=nu!Tmr#n zH27V9mUe-v^BO$bD5wXKc6eGStgae>7 zP!;$KyBuX}_`X2R`_+zvjdpR`fQVc;w{c4p)`ap;rE|wCp!4edqjqyi4LfWO zsn%$yw7V@Q-jmfbKH>rf{`soxOpm@!or^tSt96D#PL*`B@45~)uQ-fT9`BxV3U-Bz z^BcvkS@}2y#qsh#wmbP?uU>B%v|k*weasypK7wHSCXBcH#`D(dhzK%u>e$yY7!ljxlJkHkyqeC7Tq z(#h~$bsR}09LYO<#jM@MLI4IJlC6lsl#!tyOJ;8U!Vhh|rB!4eM=$V;F>t_35@|~u z8RO)NM5fe4hUt2DDjtkzho;z$16Y|qQ>BYTmC2eH`8%ukBrF1r`}pq*t;$V9^_3lO3E@<73$p5FFOx z!q!!T*ShCz>S34_2JHsvbc&Lu9WsFf=|hPg0*?4YNgBIM|51! z+0~EMLv_ct*MoGR)A_DoR9k1s{f-*e=Tl(Dqgd&^y3Wyq4Y%2oyNfTQmlp>nzyv!3hf6zh_rAPifjY>Gu*<$03fSWQ@1@WU%FU@I+YAeN^pvz_5<}J3} z$f%cZ$&U3w>Hx3y_di}b2Ap(aT%htCl)YidK!lI9ij)h<_RehaJbGO;4`{O9uVb6m z%mYQ$nAeioZ>6Ug{{7K&pRE7>(trN;`%7T+$->HU@AU7>?SWANZ;^&0N&aiN|9V{k495<5ivSn$ zpMm|pybYL#zio;E2ltQ5y@9*?dx}Wtamtsxf=$n|DK#q$x4HkgChmmrB#PW|V&{ROpB0FTjIig>)`juc$GoR9+yb$Il2)fBxnQ=kbZq>ESOO8Zho0WkHeH z_}Oo(j>LfgZO+}PZe(C$z0&4C#x&abNLP-@?rkZ7f54$S^z43PBzB+35&`OxoNfw2qzkLML40O zl-dv$SG-p976jbgJt=*MWdQ%bUNNbhwd?-dG^LSy1)}t+j2qa%SmS5G27g%lmYn<3 zJn8X?2!-$Pz;<0Zupa)NOu)|CoV@6QMLt}D|1axIhAJ3PD+Aw!!4fC_K?PX6zt=oq z(Dovpp}gZ%vQ5>0J|E80>sAE)7T3fn3+ZqFSlA&&%x4Jfo)EP)#V z!iE3ksh&kp2ju?S^nk_w=jZ?5U7xC;|Ias;;V1?iplbjE?EKW$+n>y}ZY6VETb1%` z*l`}2qZ*HN98Wq;NO(Q5fU1ggo%~+3kQ{s9%BVm}Fo~j#krgmww9J8pNW5%Al+{;19n zq&vr9bQNqn`*@?2Hs6W_;A@~Y1ppp(E67+ag~~TgGl|El!lrnlkUEwoJPMHGFLOdrW%LXohC?1 zQUvglCkqm6J>;enR4qp zX}0e6BNjB-JP@6BZucga|M0x*|B3Mcr2=_SS%v^w@um9K?2>!pZ#CqbZ-cm*j3P7l%Dx#z$I_ar)!uO~qR5XvZ*I?@UHJYg+pjJ&D;^>G=s ztjvzXp6cxSIziQX6gnW&7fmS+PFrmDC0p+ez1max!;KRBW9yNakmHkOYLhmn-Ki?! zlwTKGK761D=O>QA8QZY$TM(mFa$5{0O|}cJ39nrVCjDB>=R=(^Koes~jIGhPJTlx$ z#dx{_W*f@1$8CsmP0sJX(&r9^Fi;+ zy~P&*6IH@Z*-{PmtBroCTZgp@vze8uuIjCWKPKnDA>+e<*xB}xmpLDSVwF-Ivu-7f zPly6XJ+Xt@&ZL+60v_a1YU8Q9KVQ-N;0X=D!gOWF6K~pg-68j*?FS!<o^XY_+isT!(^x zw5HJDsM_-X*aH4zt|H$R9}>KQ+)OsR_pJl49wFiq83l>>$4h>JyW`muVz&<4t^xfD zncYi|r0e}*rB@?=-y5#Yc+&ZzZU4Var*n4p{sNXNd*0RM3f9#B z{$03gD+%8q^uWC(e49@FuebZpVFZcq(>jSVtsEE-FXF3tE9~yR-cjin%_IFf5C{hs zpJ-d$w;&5{C z@V1_mCi8K-^Z8j?WGg8L^~dX*$;gG8O#}79m-rjk+sxcHa}mZt=ajOh3kEMD8Fui? z^-X%4mU7yKfwxZv}I1J08_p99i@!#YhL z_IJG)c#QR2>t<_w$g$|cLjdoaxt2;lo#?GVvUFbv{`js>D-L8 zzY7&>9RH?Ge{oX&W3)Yr`or+94A5ZKBH}ACg6E@DFYAZ{J+inEid^kWN7!BTqw2e~ z4hTFmPF-jZ4Co&-?#mWo_qrsXt$4I&(rYF~rn`=r@kGsx1WHEP%0E`3xz%D>g|C6; zptI8-oH!4(sSmb3E%xu;O8oK{hLt4i9Ztgp$Sy^}6-Fa7On19mL04)A6jJv!1C!-+ zAHq`utq*vz&aWf3y(FkKekcqDhXx({*LDyH-~B9hUl&u@Ixaoi{`<>mfJf}}teQdp$S z`%ocT`k$qAA%xtE5S6($QMVf}{2Ft=K)pE2Lqtsq>yM!i;< z2X%bETKFB)(tN<+k?B>c@8 zHqgt+(_JrM$ zd}TASfWEhcc6waBFL2Py??W%>$toBe-n?_8&I;Pu1XXU9k9+9`qkhadp&<*zf#DcWlZ1SKY(aM! zLF<5+xaEQu)8cXiwcjz5<pDmaLEUR>#w|K#>88AD~E3$ z@HG?DncMOV@3h5YycNuzYTLyD5Jj*gBah*8AKZ4`35;|duFm5$Chs{>n=}Z=Z!`f0 zSMHu=5`Z1Z#Q`lU!eRqJunCkVx*A}PFi<*w=FbqsF`Oe$+b)Ly5pI@w?@Ek-4!j3z z4otzYP6Uu4p^~Z@u+%DlF;Va#cmE^X6sd`)lU36hZ7Lg>s^`aHFi6;fk1D)GF0d;G1d%yslPQ z1V^hmhL$E@Y)2>aZiNw|FxBeqk(_>BArF4CblxD|xoQfr2ls~hX9~z@8q?!y*Thys ztr_uht9Jwe;UuLFlp7JBGkC=L{F7UM5k05F^D1UwC=nd}AO})K51{V-Ua>cB$@4yt z66al2Y;SaFYt-8sJP1A>Ykz6!!dd4LDi-c=l>B?54q6FBj}d(>eW){aS&2Tv*v&~y znYs%JIcI0M;wQgr61skcgsQwvIqh|~++ro%&M(jmJVWV&NN}tm{j!Lkzcigf&fz|- zL#rFPg~ce)L2rSOOwaYwlEn9-)onQYHMi;xm2Qpg5jQkZfQl!bppPzRy|J&-89~(!Rdd( z4PdQPH99|I@?WQd3}$N?aWAfFrZG?7K{Hd`C>2IC*{sJuPL=5rl@ibNgih|To2)UA z@H%15`N>9k{u-iO855>QE7x0MbGZC;_yh$%=#?1izdJ!CAEk@`4Eu}lLSucka#ALh zfp9R9DI7RT{IE_~H#z$rp?S8WUXsEo|nfg{m)zz zgBn|9Voto0o+T)HUN~bMQ@99xME4xBMl`>fGM{=?&nt(EYoRIoN$ju!)WjM1sC54y z-CpnRJUu*etoqxWGJN>!J96m8*j)Et{637HZS+aqVZkXCeAK#_1^O&|dI-ZDE=GN5 zwWlvQjeD29?sA;FE+uChd{}`5XRfY5T_K%_YQ(|8uS!BdwJH37=VWzeac&&Y zE6$ZqkP)^oe~1{Xw^T%}#w$yBBO`@cMFJn05e)n)6(H~c(xyP?uebY*fS&p7!_aYq z^GQ}scM^NH5%hbq6N#7YTnfMdNuk@o1|0C(9!R6>1gi|_P*T}+?%@_J6Q@xzm}N~p z#sCBryWys48o!s)XpwnutkZ)bcgFTb6QCgzG+Mf!`dGDV@4@E0#sH9QAmPg5N=2c) z$L!jdMytew*uVn0p2VuHMwt~Wb>b}H5%QikrE+bpA_J+Z*Y5}v<|8RP=tYASk|F@Q zl_87SliQQ7_NVZVxWdnCR*1c-Kd?ez`30p;hvz$`&w@6AqarWBDQC0dj0p6{ zi}@QkW&2dmgNdOl|?4JRk!`PJ$SW1AmT%LO(yu-Wfssdyd>w7Jl3dF4j_b?t1?0bqRypc+4M&qWk153BZ-#+#sqv5f_vb`o^1#=O*4Y*XcUhYrnQtdIdi_eZ7F15cp!2}M> z(Nj9h)K7Eu)7}v%#3FKS-C8UU$5!g)->2N(-q&@8pp$`=TKR%6Z2%R|vZa;)sUy#! z$Bd)Jb}zNJ^N`k)&&%R;vd>$sM$n7^AZx!vf zun+6iVZt_Z$MB(r$ZJwO=l^B1XaxdvoPz3)Pc|Ru*%=dAz(A8!s7&syTp|N~uzXU~ z%EgY@)wv{_)4nkYzc1RaLA->6^j6c>#ne7j%M&>ek(m8{1xtg^2_}F$iBd;ppfpJ_ zbz1JLOorLgxFZpA5iLue*-3vYcgS5vXjAx zW~@7{;`I}muT<&54NgY5Sb^#;XOc_ z^HLuNg2_Xs^V;PwA0jU03&?1aDOs4VRxMgVzC2G<;&){@XAvcp0?go8 z`&~((WDUs3W+*IoK4S1#xEhF^?a!m~@;O4{M0Y_-n~y~d3F6z^5oTqw?Stn(ia2K{ z^Fk1RH8eV{PenAasW$ zGLnSxh()(#c6Op6XvBv?lg;M4mcdImVCr*y&RZ@}Nh#JH_3pk8{QSl;F`a4+(Gp>((*}_hlD zk~U~WLVECT02xcs>r9SSAgfMz=|nN!YD?YX=tmdep?@%3`6xolWvCb%R}9>rF$t4? zlPC7%PI)NfD?x(&{;KJXb!jNb1D;P8g>0%vDVx1!Do{u#ce+dfca3nT@Q0$>~PIm4^7t` z@+)2|#1KcrdGA#ou?JDh{c$VZ(m21funO#!I>B+EN&np!I#!g=ls?hN)6* zy!sQN=r&Q&@fP1-V}Y0l864}tfF$q~GV5P-1|TF}5AzrFSWfy>fKN-3CybBsNq<)j z4s`{HL)ZJcg3u3I#m9_pce-$yl7+?Yi(5I!xs4KJ%DFE3el%*E{Oy3DHj_kDqk6ArdP9WcGJ#&N zgnKL1W-5SHjNS+QC4_#mE<1;Fr~I?9o;HQXv^>?S)cXv^FC1#;Gy}2YH#aDG=L&EP z#-oA$R0ghxLV@|D&@9vO2LhhLRGGLEIL0i$dUfT5>-#|LWu?S}9jzg-4=@RpBFzfRmaNgYj&hmR zb_qe7yTq<~<$flk?UKm%B?yO*-Qq_D8MB1<9UV?Tx(%z-{uZOgoBcPcPus?nuzkb5 z>py&aOriZzybhNr90hXpW;W^PHi*3>B6*Uv=B>2rkxHLUfd=C$@VnIDcuCMS$Y3hX zI;Ou^wW`CynYC=83Y~HKL?Wv0iiT*+9W2vm$&6EgcEEJxf5ERTrVNh#;j9rxaP+e# z1+f=UOqNZKhDK#%zaL{y;><@F)o-#>0b%6}s+c-;5)PRdwEK+N-C+oSa{$B_mf+tf zI29SxZd%ZfCG_i5IB~KVv`4_`r3_drWVVY{mvyr{tGba0_$ZZ)Bw0J2WT{}m-O)O; z!b_-X6+qj(&R$S<1^U+);FFOGMT!T6Z0sDT$5bf7hy<-qR_|06>sin+>*Ev^t4TFj zj<0U*g(_l-L|xYjXH;g!hV4-4mLvhrg`uoWFDh1Ud#$f;glYnmAQp%klDgow{?H-` z;&y8qU=45^Gcfdt*Ot-Gr>Uy5q8v{YSa9j9cE*(S`R@PQ3^k^r? zhiyJ5>_|{bu_sXY37dXYMJGSQ^!sHO7zl^Fx+CI!{OF6R%8ncD$q`63WM*Hle|x&m z1sTcs;)44E*f>J0K!!V9&}Lh)7DrlI3jysaOn`(a7%=;djIWWE~~yT$wQibksws zmL`h?oIZV|J5N5MH}dx$SgtIUX!rEF{Y<6Ow*hFX1Er6f^Y~1tjB*L$)Ayyn#wYT` z?>)KlGN@jE05ZwGqJVZ}WwQwn4hheT@kzfYow3+|j-QV3Su#)a9vIaLHqmsDockK2 z^Ou89MkqazHhK&JtX<@j-pWriRYm6}NwED$E(-(bPQrORldceHG|yMmUzYuDKLZR| zgN9b!(?YW?HYv$<{h)-4#4^Z0H4b?oEQyp*t-Zc96>$~*cPb@KwZW%I*{g-2&mZ5q zWwJ_3Avk=KUh5%0d+H=VgY*4iG$<@QlGkCHt9$Zc#Po?SKbBz6_ad-ONCCOSO58`MLIPSuFetIeMZXY zu(J%?C|rjRdEW(tRqi-Zk#XC)%IZ3_0?MSP_R5j*%`C_dFXVGOV*qNh_W4I#2easu z;=yr^P@BB$yP1U2N*oA`)es4yHd^rS&H;egz~o?%kRxeSE0-wv<_Hv15I94^B};>8 zFb#kxLbzF_!gqyZ&`a#443)?gO8NVQpMpc4?*5!wYZAM z5{7$7JyT4(^P~Qf#uCxdNI6UyB@XjgB4#8&c^2;#md9p1#UzQF7g7lhALlj?-=mw+ zY_rn|T{qIhf*?qd;xWc)Y~r23mJR-)W#m-I;CGKf!z*;#L7}%0F*PEZ8o*dX9y-Sbe)ihn zAjav1BSwErNJ&577@$pKM6o~0uPvrVLem*(_^$zJb*cmeS9@EjDdh>r)nTt*fR%9V zdOKnYo6l`8MFQ*Z%OzhxTXAMN>g%5kE==pa&`k;12JMC*dsur?olXbGJ|2b^ccfw- zMki0Ui`1YInAMOHPv3XzT);*5YD=X8(0WDy-6PX)geM0rO@y`mf2?_QdJ)qXbb zsSr-8dp!N&T=Hm*{u=EVw7{y{NFVcUr~vi$aB7(3cg>tHxuP^(HS1rXF zz+bbgWWS=uCd^98Q*-7NT9{=GqEc~+p+H9J<>jlUc4N(CC+cN`tK=#wR+q@{Lp5E)&5tKG ze&bvnj79m89w#%6&%%v7e4yp#hSJnV6CWRsMKab{((!qfvS%z6!6JV|thar7Uav9j zW$k|ppVf%H03E|nBIjD7WAK|F!g`%EL9p!k{5qrlarLIQg5D2P4~}w4DSe19(MA4zONRni!0ZLLcj(M}%BhBw#lH`3z z$@FRCEgcI``YRmg6#WT&veeip;ZErAc9a}S;-F$^H+RSL#OJ5wYpDc|E|z|deTLHF zL?B9cfu+%QN6Bv|<>^ongPAHwG?lX*6nnvvT`-=_Wa3-iB|%h1n}e$gN;Y!&5z%ya z;cdn^2`j_cVXvM zL62+Q>;c|ea~jn0v8ocpJk@05_N@0CDZpl_eusfpmh9;TK-yYdU2v7mYvgobg`VfD z0EhuQg>AA}%riF)QVuNVHlu~CN*$%FJ_H_eXbk7a9+b*8Ef!N07Uu{b=UUQEPwkn! zfC7-ae|#3h`-if;mi*=e|;h&e`tjX#{;e7glH)-7vbLztp~ zmR%Wbj)MaG!~`laJOy&foqi4@k@NC$5)mKa4zaTlVz*?ct|p(;MZzPS;zupND0bPe zC-u6Yb7!JoDI4H82d?nQk8Q8N0pRS3&}hoA$Bzcyg_yG^c^z>cZ?El7$lN!3L<+}i z!loocyY6z)E811!w0uJx$bopZPpGTPcD_p+M6`lD?P76hdvN}$Q1S9W|LSSU%WnbV zw*rF$2|wg%+X-!jelGQuJFG2WZ4D-eY2Q{f9pv2MF`d$HsK^=tNjwLxhb5L#^%D47 zES5LUJC+bANt`NyoU*X;F|fr!PQE?brT0ei?RBvea!k~4unah;w~gXJN%g#i%Tm1{ z?NFppZ2Z><=_CK0AYz_`&4dR*e$*~Q7$3Yk=$z9xeykyR=xq1Q`ID`h&bB&`ut}q+ zD`|M2<-UGs#;H7U?k@k__Iyh3SyxO~&fZk-3l+uk_t;j6>2Hfo zxZB+*>sSoA<#$lwhNgF()(-|wL+c#+Co3j8lhYiT)IsZ&mDrBf2WU71LMSm`U-$X9 z1+2P0!^kq95sGVPOhPrv&n$ZPWi$%lCsfOB_Yegw*{z2tiXR{T;_1ON`iPrD^4$+|z54Lp&vqpQs0?Kmg0A%D@2Hpwu&fYC!>4I+-g5 zt+$~fWAN0C<#zhxLi!F!*v8U%Wqffswqmk7tB!fW=9lz44cRE0gE_=?hcS0q4TFs3 zj#n*?nR$ZPz~+Q=q&?Ckt7>{#sI=MYZS4EJhW(~Y<(2(P)6msptxXCa26gzus;*vm zNsY!+-C)ShxtCd{C1DwZ>>L-sW=vS&xN2_w4L`chCNxhGMMfuovC-W!AQ@>u_2N@| z96Pn&El(a$M+h-pRZt%$GvWL-t_{+@k(2UvbXdz&OyhNCSGS$rKB$iC4(EUK5*Zq7 z_9>P!o43QP31NXMhe?Yy&}C?Vx*fg&2k6BSiGL&}a57m5zPnvrv${?PSj!cwtrEuq zX&2yWZNlznWttkvh}>Fb~=oh~DWV$tOeW{YGg&o{-59yl0y9@@~rHh)=vh89Kp`a+q)St6tGr zp|&w%#zD`#?YCRW$En=}c!TV7+c1|b=y4vKG#ZhTwe0%5)ceD zaXOZ0&;9A|kzm;&KAgr=BrDFrh93h7Of?BXlzx9@0BNsYEzV)T6-4(`) z_?RHN{fYhy6An752pv=|lc9*|*6Hq^GzRQDk|pzi1vxwcHZOp9VT)|O*vCh=(HG%S zYD@~~Ly-hIWef%t*9~Oz0$e`VE`rn;UsSmA-eqVir;O<~Fd*+cp!GiUi=ACX=x`X2 zPWpUJWQYXfsduWGz>J3ZkPvbp0ViFt2f;=Mr}Z~24c=;08{u%GC`sA-Yfofa+HYbI zrM$$(k4AkkKj;S1C}piU_-vRjE>|Mc;*E&j&bGCaN1~91>MXeZ&;@7M*-^cD6|}x) zl;h|&CY+3L_86APD{P)Ep+qD$exF8gwCf2)b8EIa^qyl69J^uriJ(#{Ma;(Dml*xYxO){P1EazQ6!I8P-XSJri+tc!Zczt@r%g z-s_)$Yxsr*hwseiAR~tsQn1Q8H+%^%Z6t(29cM>3rptx^3gifJzIJ>9Lf=ek*<5fH z77H~4Z$D1v0B7%nOnI$5x$!==U)+VuL7YWbXQ51Gp0(NAL4T|m(E-6gmN z65O5O?!n#N-Fvck-u-@G)&KWFSM^DEKUFz{CyTY_nseUw9OD{HZA3*3I!*BqZ@tKv z9cLl~#8~0Cz8HvvkY6s|*?*=H%K4>v@yGNn- z$bzADGxt)h+4A${_5vtY>iS|0%pX6m7Iu|{a<{W>V5Abi1tAjAxzVLL=f$-HnY&s1#`bmsHs{Td)xNUU+|p-KTTRoU_{kmXAo54Z#akq zJSZMQ5i^eUU_Ye8U6_)R^t3eVpCb-I_1uzbsu`YzKY$Vmp*THc;&_5T6i&{wN?`AC zHC=w)Njg5%Y%oxYezuj0Ng5<`c|`s4WPqt2I5>D(9{uq7$h)zI>9&s(Rm_{2Cfz4u z3Bq4~9p4OWsbarRUcqa~Qvu9$S7^@W$-~}UJPvwN7k*?1+9(8iaG8O~SxwRP6@jia z)ocUm^e#yI`Ln0;B~h<*rJcO55`P@@QwUG5^Q9K{@SK{#2qtx_5sLP;HR3;IVu^1( z2y2*6_%=Zw;N#*k%d;_+xFc=EUBbz?78&rn8=d1g3#Jp&nx zN+=-QqEKO|D^m6O7M^~?jbD9ag_L(E^(Ca$aal&4~AS^Zv*Q%eCX6xyQof;(O zEk=E3;x;zu?y<7IX1`<%h9;U~B&b?^Nk}2(WD-e80zbWr+$WeN)=nG#Uc3BA|5o#D zTghoP@+Kh6`0D%C+8V7S+Mi*CIIQZ^sn4;BuafXb4aCc*EKHXZ3^QnpRI(PVxLy^% zRcwo$QsuDLUn24#H%chzPq{lyy1>9GxJQj9Qr zBdsQv*9*51QTB|!BwI*xrH(c5tYR#mBK4?|dc0m`PB(8cs$V!xG}0=$UA;q(4BcJV{l=L4joMukJ#okUa<<&D66v4*7^q7IR)foZX=Cjne5w$;`sejR zcUQke{_Irz!DhB3K9nByZql7Dvxn$DR5AN}ccc(SbLsjmO(e6rK018I;$I1Esi)!T zdORjx>m;-tC4smGD5CsG%aCsxKZ^~Ya#O<;8S%oMgnGUntsy1xM`fV(PH%8fRz$0# zsRGJJ_2Rz1&bQM}bX&D7Dl56myR%-E=O3DlO9sG3;A^TIYVVI)Oon)Q{O3-XP}IQ> zjXAM$D#Sf9?Rhmdc7q%SGjkx|bDXQJqGJ?_LX~j#OzDm?0#runpxBMRRWMOBrk}Xt zMs-QhkrvJ3UA}lPt1DuPd-f88baezwV!SfCm6{>9%H#;?1>wQD+Z<7yeUk=k6AX+ z1x_&XdHI?w-w&@6S=%h2x0=5rhMKI|&=-!E&sU&nfY$f4wBSTWXtAJrXn_Vz8S7x# zKHlKw5==<;vc{NK%fQ$Bf&qrG%IdYF+KC$zRf zJN-cHO;3|#7I_d>g^3j~=YVyrbu)6~*pNOm&os^(X4s;bE$Wzf(! zmeZx_qJ^;jC1t1@|NA{JS*ni!euLfwCxCt=M{XY-&??6|i!VAOr-vt%Ph9LKy3F}_ zv${P|mNf9HTPcJ7K`e-sIMxyWuh-O?DW6iz)b8KT5+uzwf za)IjdRsZAyY_B7;QFQuNhST^Mb-55F6G;nX>Z$t)P2Ys|fixE5U~EVa4lS%k9`9>f zeS1cUs2)}egVHvlLQxv_X!sZJ2ZrB~l!Sccj;7V1z|Al9Ow?z09zas}i!vXRnpCZlo0@z`s6B+rL zdsSL;me(33!2oYN7eg@*WB=tBfn(OsO0UoNYacq0B3czU55<}4dZY7=1`T#tYd4*4;83AA{P7V9FzPgcLU4dx73 z>H8NWnSI*=Hng*|hRGIURWX-C?^jd2Jma|b%$8c$*tdqs{_gu^F|PfF^tFnlomZ3q z3S}b?3=OMs*W?Wo%L@ntbey%bl%?3IelCPM)1H#LicEPwe+Z0V-?8}9cm;ER%WC5t zp!^~B9#_nfNmHv<$X6>?l-|F1Rqnokg@ku-wj6kc5R5)F1FD{|a)V&c0PS`odYLx% z8js;Ou?*@G(79}r4}RUtRYU2l&@Oh5WOG!Q>AW8FAIy&lXBzFjD$+o6ORK{CP|d~J zu*3tTUvbDUNqf`TyDhmrExzIsIeNl6(`ZJ)lCDK_LFSc6Jb;`duOH1sxUc4(|0J#_&rQuhx01a^oU=u z@UF<~U`M)S<-AThU#%py_2b&jf{QFz44*O!3wX=9ek01pdnY}87Y3Z=-5%x(2=_gmTJm}2>55m-b=6O$vD z{q4pG@eR+op~g&bHkMab%qqe#(h&R8&_8&0swA;Q2cmz9CAusH5QR{m?4Gr{JLM%$ zy`NUIvsh?^ysk87Xz{mAU#KlYdxl8o;%7r)D_}~#9^6_~$+~&?P2KjfEkC4M1%=4o zTo9arf?wtOSnpu*<$c>NIKGC?0lU_v7B^xd*fJg5aqa2$$p@a&Qi0MIwY2YE9&>ma z4{YFvilEndeqm@_K|pCdTuRTrEOSD>B-x35^1>0_#u(U0hQ{MW6!?~}ue6v9hq{cx zjYQHBL~sn>Qz9bY`8z^IZt3-ZeFea|eWSPjyYxlHfi^s;Lt!=~uxJ-K3?32Wr81QV z&|cyxa0}3c(xkFl1c?aR{%SlLu?=!g=FyY?0@Hd0k8O%G`CT}247O<8{KnsfepCI3YXP%I{tfhL`Y6h%zPhPq1=3ia%;}Dn=eWhS z!|#%h2!NBxQUepO;Y76B7MBbUQgxW~B*PO&vwJR|4J(_eaOsN|3{j+Fg7r%LTV2B^ z9(Cn8;=sMk-vo7he%dzr0Xq^9j(ibc&(0I#sJ*v%oc#*i0ohjBG@GAWK==+Oy;cLA z(Nz_f+X=TZ!@`$GEj&`8+S1KSB0tbd8}T}~WG&J{DTtxGrKi6G2WyeU)^DG+g(1V; zb91i&T&ELIk8&CEq~kDOtah>Ls6Kin)$7tR=e!SY0@sI?03@z4BbHcgyOZX1GupvmR#ADtp|t0a#;y91}ucFerxNB z&V+f&qj@i1>a$5*-6@LAn>qc-Xyl$0dYE#P_Xhu=I6wmW!QV6~K;cm>pdS$6V`F$o!yl+zlx>7uDO6K!WUX=wQg}5KU;fQwA=a*anu5Z;5 zggZEI-8<$uS7z5v@A7caaAkKNKKO&wsTygz_TjJi!Uf~zgk$%Yl!0FrJU*`V^q%2x zIljQ?3h|2|63};8V&A;mEV-8UUCwW|TE=*rSjWexWHyVq^MPEnGa4rHjV!Z$Lu-cZ zS)_my*7{(QpIs8nUJ`;7Sxt1(kA}QnJ2XCJ zA7|R~m3BGKJUr1OJ@c*yy|g@w9&!ADqaxdUrfrjpE7&vJm{c;WHeaKz_MXpr({7)~ z9j`a0qx+yC0*%{2BJ^+^7KF+9BqVU$hNbzPCD`=wU9PNco!<5=7UiaX95-4fbAeZG zF_*MYrfXj32Vn?&`mpiu4SZGv}BSvQ}fop3_kSzE|kaI z2-!-oYNw>@xR|?Qa#Tx@JG#XNZWPvSM)=&|b)Ykg@CDBcbE$xp+wZ zD>A#Eo_F5c6V2X%tWqt{79Q}zqSF|{qf<#wSqTHasEC6+6?47qQWOh@UgtKGm=vGe zqf`!|z%YDNdPVNjTdV7;ISp1*0r&@QHj&%k{ODi6gc0JXajAbeo%TG_#HNL)+H|Ir z@u5*TP@TW#dky%~uQJ1$FlNnxZ1oqN)pVRMzcgPA*3>|uSQuU`0aj({YMKB&&dy7 z49dGgtpXf`jSEQ#_s3N>iUDov?hp(vNGa@IS|gLe?Hy^`+DyM=PqTvbhC>>KB_j?%GT z9;UlP2Ta_Vh&Ny9m=-mPr%M#+$>hRA$GUKEEgFZYS_d0Yvc+ylfKU}%MxzufKrZEL zKtO^-_#`qdsiNWd21<=djWu}j4DUYZNLtwqx0hk=$; zCbdRmT9E%2N>69eyJYERvsjz0dUtzzwO;qr=p%IAcjj--^G{k-%nt$lGDX)w83+>NoeK`mA}9K^mWu4 zcbuX-7`fro{y7ZN-stLh+82IOlQ*K#*JIuHxA^_Jl3STnu?%pZ_j!WXgI-=opS!md zo$ie|@%G)P4aX{gI5%LM;1TJ%nL?%Voe4+9Kig@qt1uC)nBB--c-gl~*MKU`h1C-S z10edxEJ7z17vkmC{rpL7t+h3F+(7`(o-`WlJGw_5O(EFt`ii{i`eZzzC!V_M#x;h% z{a89&rkUSB7o3!sm+;ywqN3nG!I^y^YXwfWje04}K2p@6z^2oRaCyU~0j=iV&_gTj z@4U?4<*3sQ$e0DjinZXz^1)9_i;c)D@> z0!S1)vU)wp>8@vs+;DEi?V&4Tb_d>Bj2E|~vnfL3ZZ|vc1Os^X+7)D-h&%wDXpBkl zu$8b#HJ!`~?Q*D*|E^Ds68TA`Pr087W zt3G|umer|!-{+q-h!M}we-hTf^KqiY*SXAKdBD#Ca3KkwZ4Y<&{4VIHhQm|}pZlkd zex0x+7%uBn$>;&fA~b;NceVw2h(tr5*IxGJ=v^po+EO>uB+yfNu>@Q9jSOuzCct*N zQKvn(byZ82dYY*OsJ@3zQAg9py(QMSdJG;U37cj8;qysP$YA%L4So{?=_TyvF9F}b zi)5l~kKq(Vlb5^6res@zBmfe==|d_7s*bIQBXT1PEgQ^{dx7B)ee+o#M~s6loyy`| zdBbNmJ=eG2G_>i-6_(jKV~T3!p^1|%xXWPq=ZSP~=+KIif zh@^1QcQE}ZyD4NfXHhuNdNs!|Q)=+Uv_J2K{}Rse*Xt{%zs=b|`}*MB7HelAm_R`y z^Kgq#8t;O9FEFQfUt1=^__|a}J%OksPN6XLH?8)_7JsG3k-~t^4fbZl(SeOXBCAu} zZ;OVmiS>%??{rK?+;`+gxO;x9);H-qhKn+b=n4)kd!Y8)a=K2ayn@;peOufKkVuvfFrZh`ty)(e#7@;2T` z7hNst-I~XK`b-&W2%=(j3cnQmqbcQdBm|Ne^P$=TkV$vWc%2Zi#U>SQ%XG>HEobbf zL-7ng(!c~mU{mYg;4wyr+gq^ju?uC5UVn2GXt2F&UN|lDy<2lcI$T5|BqYRPvnr^Z z0JJ5k9J*{uldxTSyXaCr+o5hSF-wUo2k$_Y?frUzo)hbfnIiU73FAkSO|JnocU~19 zbbucqop&CyZHQ29Mq2Ob$}y6^hQ@kLfXL(2E#!))!O|JB$Qjh_GoJ6?dkY8WqZo&quNHrh=c+wg-hNa%Ri@QY zOAkR%@)aG@4(Q{wB%%o|vkp(3L%n}Vb^Doc`CDL_+?nB7v9-vr7R!Ur>ikw;R7}Of zao#~G3I0-Tav=)v*4Iso7_mCWT;RvwGQQ?{HeB=Z558lh;G8)dwmmr2ESE3vB3!dC{36APNZ3v=s=;f2lmR*%LI$wwhLn+D4z=1=1@u;p;f3xW?hgqP> zuifyDA5!#=2@_CR3k_`E1ll`fTcfgIXCsU}8WWX>$Ax(WdMA&$TER(nj@{%61-aJ` zAPv9c82h#nBIP}wX2Nj zGt!B@_KQn@(FUG6k4}vaxH-lckyc(ovb9j0m7!vmrMEV|SG$rii#Dq<@tw<`FPiw# zqB5!H{Fl;L=I)d!<-Wa=Iz(Hxj(+C|8d@ulKb|0mx;yOR>n+9~jDuUam)RAUOzmYc z06_0@avJCC>>ddSKzj*D`SM~D*j*Z;Q*#O*5=_~Wnl{Ga`ov2jnC(xvXPU19JyHCn zOqH@j+sla15we0u@|hRC)+O>-A>m@<^d|5k8a2=P0{x73j)g{n6m^gkR+WdAfn8(sn4Owp z8u^Qbuw=1TV>5iOKshd47m3%GQ0)jVB2B8FF#AFbe=kHHJ?7`MFr!Z|V`z}3?s)h^ zx#t0J=>l^Q+Tb=%X}k6_zOztjTzst7QViox|T)Q=q;Sk!FfHRFan9A@|OY0WRap` zHyvxeUe=!b+@gWN9j`By+Hjjod5?{#-Oi(k5n)$S2Ybc>dOo=0T`vB+nkEMZ{y#>6 zWUn{IWY`g@o)G|~zA#l(MKcLwlxFA> zPvt7#`!07)Wa3RSWRE=R?{I%^nI6idYTMLWr>k_x<(BFJlc(dI_27u`Dt9|Af*nrj zUGZXN|1kJ_9XGo8JcoW$5A{7 zRnPi))^n2y6FGmsb6r2N3~vvMTHhIY&*bgLt+gY>|22S^xO+wbew zj@t2j^#|$!yx&711BcdF$;+!)$4dWE^O)`zV_FCr+`^YK)MM@KzVO4e@I}NmM}2>r zN>u>pbKnEwiDC%v2b^H|ixjJuLf8)D9f{7MH8OR0s%GqfT(x8a7WimmEpbzFJQ^&U zCAWNt1{Zv;Umc?GGv6kt8Gv&>Ui2sX+>cpCE|j5{&~Hoypkv@U8N-RfC7~lZu|Iic z?tS-g_B}&2z^(qFqc8^tfqf}Ju_~AtEC^e%r_b(Cd65Ry?RXa&%y8!7kH7*3pt0{a zcynyIL)v!>^Es|(&9j?bF5$o6l)tpQv4cn}fu6{dGuDoG8MVgaIv;oDv#qp>FkW*Q z)E$b8L!?&-j&84p*b}V1(U%dB!5FXn%r)06n7akCtSVnOosfH1OzEZYw?K;8->q=_ zjUYf^7hCl-j>tYdxS@K%xa21`I$n7@*eGkXZ`Jj4>_Ag;5}_}Wpmo?rQ+eV8ef3_XR*7PfS_ql=u^%v$NfPH}BxUX9l6+o4hnVE=o0;S*r@ffuS=*qph0sTu&^fYdUlg5dKoEJ8 z5bpy=X$pW(X?9C>Am!IWbcWGGvfSaCA3%xzLZ&wM>Qwu5S@9DOtx=cc)%vL|l zpT$SWwKu=LFS^f1Y_SH9xkD2sD?h8xaYlUk1osBDElQ6snFk5RHip}BRR~!OEiXqd zPVFmM=$>D)QzJuHrf|&%@ldAvzr5imFP2X#xUDlF4!-u%FnSVMn+C{!%zp>79Xd^C zzLUdltj00FPbx2J>PJ7my_@g7dScJr>cb_^N;8JGnAwKFu?)g*kdC)*Cn+|EVemOb zPmSYF$=!$ETKE!d^M_goeHlI&%`1k*2BY}SuE!U~4qy@=2;x3D^S2@238H8E>CE{u zt_%~vojYFhD*57O#=5j*^6Z_9?pbQlDiqA+7^}|}Ho__yXWIRCCKQsm`+}1B!uY6j z&9sD~s&5;|6Lw9rl;|aDX*dg3@woX%C~#qc`gsQr09VJdo3m*QB&0%wk2Q-cagdGC0=Zf9{nQ7B)SSc z%eOFd61+z?Nj@tV<%mI6D4kYlmMhwXP@kS!l@1osDcRJT+0Tm^vZcccOh~m96z`8i zSPQj3dUc167?`|0J5wVj0UsMA%ogyE?otN{6AEGNgC7!ln+ic-S=pzx@WbwvHck}E zfe|bEKK0kT5xz+SxKU5;#i^MW7ejg^K8r(MptuyQ^ zEa;3}-vrx6J=>XZ=rwV^cO!;Vhpq$`XE%FL)W2V22}2ubDIIh=76V*1&m+sK`JXIQ~k=8}#AOh(W6@PpiC4$L{;J|6=+OZK5QbThIUrPYo zQNEOZ&fzfY6#8ZKk0wjLIsQKDL^_t0^zF1pLMCIM?&)CLZf z=MoOp$58LACtaWjOt3K(#ja1)Q-mNfVGLi3d+lUtVBv|Z^!mYV%bQFZGhsqhLkBR4RLx(~7v&CYPCwqR>;By+}y?Pbqoz;m$h4K6&;B(XQ zXPb|N85H=>$6Y_;+QT5jlnFNYc^Z5E@c)_0aG*s0`6d#Q^nWk*@Ba;aRg~PH!PF%f z_2;X4DF3}b690UY$ok7am!*zNrCs-+<{9=%)Cc;7zJ}PJA2_EwyS-K@UT`6KVW1I{ zKwKC~NZxqhgy0T_sj*Rp^Img`)kRM>9|Mz#?K(HfWLrXm8DCx)YZ%CL$-Jo<) zfejwkX^K;|;alHIPptoY=ZTI!dbKjtzJ6-YDAVulqdjk{l~6|ZiXozc+?f#0L8aMUPDwb4Un8@$w9c*KmUVc20W2KaBYh_t(;CXIyfPiv-@*9vuxfS>srq?FzZjt z$Csc$iSB`gBas&J-`jl(O0nmw{2%w$5e$`x|Mf8I{`}(qcuZ4z|6H{}#=j4M`_J9^ z?-SPy@XosUd*J>1EcY1YD+$5={J(?8jsP}z@_!GD;`aaVqN4s!?-U7Tnp#RjrYSvD!;8R5z)T`tMezvV;uU+t)Awu-Rl#39Jh5~d5Z0-qLl=6=8nNZ z*@WCd4Q(PKft-d|s#uK#^_`34nrI3+t_PQ{M9*jeRjh|P!>UcYl~c9{F3 zem1NhJi5KHBm;wXbF`Kh(O7qXJQXqNo#R-#76DZofJtqD^z&$v-jdnW8{i_1&UQxj zumqsoXu$9c7)C@=EnbSwio@F*2K?S==a!DK^ilqV;`vAbLy`_eYjwo^bL$^){=7Mb zD43v|L<5JCKEh}_K2~u0Ay%T^{b5sUtayv@!R4yn1>pWt3*?T$WMdg(E=8Z_FQ*`IxvldwTU_ochIcvi%klYaRcIbnlmN-3 zD1wN`Q9HhHHeWd{Q^+rtcZON&0dM0OpM2|ll=*U_W9Lk<&XQ>%9Po;^?d})L-?3@D z+TJAa>c8LETL;P@3_5en-W2*c2eSo1kQZDM@n>#}s`}4;L&9V#19$2bE7|@+LDKKy z`{VYK=a^}92RU7HLt2C+b84Y`ra(_1H#=2O)&$Up@)q^A2 zL){K<(!Vqf!e{b{Sf0F3qTLFQ1F^7uhaLer?~)hm-x{Ci+a0x%KFnNe{+W);!PNKn z3>@c80$G1=w6`;SMu{ASuRiAC+QIa^d)4~N-qf+TCT=#B6Q3g+C*z8mni1!e&wTGO zgfmlWR8x7tq}1N_RU>}a?Hq^qeHyON}lJ$H&t#H?1FArEv1kQc}Sz&uhEsqm)0V;-y`o>yslDzdcSGoyh;k3T8_MO>el;!~F ziFl!XFJC!rNWY4QtgDd7tE9GSP1K3_T6cQ9dC;JkVdJaF!(!oS~J`n(qJ& zokGO#EOj|xMlQyB#O`r**reI)DLucSBXNMW;D9@6SI{qh zaYSYmj6ISL7nVeCk5w|sM7<@8XNibO`=Fxae|~Ch??5d+WoL;!>2~cq!|d^h;DL0M z%4UGq8%rPOi}Fe_;XiZo^CI}Xq!uD!>NL9+X{EIauy7m}hG^FUjkY(tT^UVmwL(6mYFSG7%r6PD%M2QTw zCLzfKZsdG@)-D^(Qun6ts=(d;mkp{jkfNb0AY_-$1I2hUjoqeheJEaGQwAsBfL7(l z(&FUA%ohZ4CGJ1lwW$Abfe>10qk~iMqezFK8L<+KT;*f)`^%6bl^i>$E!(O9$!PaE zy^u1AB`3b9;j;e+vWAs+*2?wHS-Hs*dMfe8fFEsrp+&O)SZSV9;?DjF!wbxIz*XJ* z;qK6l5Ck}XkD+CV0?w|xw{+S!0pVqVzfj*0QE0tOFz|JOc!3G1a@5pzt`U4(j!HyU zlsJDD9ZFYqKARL!v>#s|zq=&lq`5;SNMK9;Jl&>#P+rWADT9f;#Z0DzWj~d(q>L<&6}NUB1e>)Fb~hpw$0t ztOj;LM_RQ?jKC_++gNs2DgHfO6QwoiBCmCYQv(*yKz(}M1g0a@6^nM~9rVo=IQWr-DXFwNoV~GN? zDReLn5ynDN7<~^sl1cc%lX^y=qxFPiDL>p?_>cG5udn?(W5@v>{}BNbE+UB@NFye@ zM{@5M+q><~JJcT@dM;mKF!?y;{{Q+D!1+C3$G?yAfQy*%VN@a{xfXIvZ{_u01(_6v z1HW%>afBN5VeEQCVOr)OMWO8wy_SfxDFy*9n{SpQs?R9+_J$MZT_!r??6AxMl{{-& zKymwnzuaW7_6)}XwSDA1nGnUw6v|XEo zM}xPJ5GFX9*oqrpbn~I+;dXHDmM3j4SJ|UCp1SDK8ag;}5VwN6y+_jCaCd&AUK?Jh zEbaR3oh?xMkTykkCnLt?D3Jv%Y`-t|HF8wzHEP({sI1aA*I5 z@NHuH(H7F|d24$&_vPADBkG@F0fYaaeb~SCRMr#s|7*9`piJX@7Z`X?>(H}&x|teq z-~^W8tX~~o7HA5B>PZPA5g3b?JTUyv#TWCx#q4th`v1)ONAp*|G(WG`8zMel5q?jH zR=8+sm=`4PeBcsv(c405>*~(4pKJB)yZ=#s_2`4jvU`ovM?T8)leIO*{dX##FMpTe zJskg|-YeX=x%YE!^02r|^|Z9k6q1xzqWODmx$*g@1l-qv#}x3qr+W9;K7n66@2TpE zh3<0Zd@j)uvXJt`GVddCoUic2k}UnaDFA1Le;1{fPXttpbytEk9NWY|oEK%pBHkB* zU1E6BC*ZKKtIR;T(&m3tnI}NVz`#(jkkz2sXs3lG_nJNkqoc}_RO?1vWId+)Ye04S zPRkqTbJM3VWb)?`z|i(@iJ(@d&BNt+3;F$LVhCjji)mM##zU*$fT#*Um z+qKpHd1KTq$=f>XXMXYkY-9tP+pNk5huabkRXu?Rv0}K5o z%zM=f^`E!U&i|iV04D1)ka%LZyDu7q!IecOcmLCh%fWg&=m89#^QwkNelqknzlrXI z;zhEj#b!#`J>{rX82ddn-~K7<__){AeSXqYmHnRqK+*B_sO;SMxHQeA4dru@0|GYG z>pXw%FFL7thNH{QvK7&ZBCh`)7F!P9IzJdw1gvk0u(_+aJ5<6QgUi*fAo7>~pQqAM zi3CVvjOn6Su<7@Xa1|-1K3z^Z+ZqC}?5xp!?*}IjByTIfrC^DJz7~&5^-|9>daJ2t zLdRIg3o_M7q_Nu1qAy-C=d{##2d13S%AemJzf-}YC1qmHiJ2FMlR)F((DF3f_{ha? zR3EFxsnOOIda^U4bF>~hW2ZMGMctwo2aWt{ta@G<1%-gR=82!!NQEA&2JMaJMH&4? zmG>$TR<1v`Q%1jW#F#4wCyRX($xdV%{;}0Y!}B^l;Mjpox*iie&nw$Aau7T~4?sWB zI%}QfbSxlFgt|$5Aac`@Rw5<6#k!&ScjSvRa^-Sg#d^l4L$wu}DXSWWe@ z>3EM}6`NVQgyIL<)a0q_G>)$+&PONjfP9)5L@UMSRrU9RY+2lYMx7Tm_k}^(6480rcPn7c4H9QswBv z-)xZ^USNuXiEik)Bk(a{W^b{$?03oGLf8~xTk|1QcW~D2pA?saCm-NCB@&PbyvZKoX-ozq6}Uxn-^c zE)7iBm*G#Jm$(lOQ>pB2A3Xll`Mt{J@vz^Fadb|GH^!ffRmJMh7ISt`)er|0PS<4W z6}ACZT&%@flH4^vo@PvnZVi$h4)+K)XK59vYC>_RmHGR`y{w?nxwe)B(Z^~mDT4Z3 z)19zYi=$qCFuwofz1oeMwz_ko?c;}1GU7{oc3>5Xx$7ymP9vNS1QR)=q%W>P|=7*GN2b(sJO|nv;vm`Yx|vhcz6t{vpPO?rRKQa`Vo5LIqC zE+FH4&K90lPuyZNP5rfjt%~kWrE;HN%FaZVR=E%J)IKgn%kAC{aCvsPor4Dm$F#UCslfmEbqkp_(ANc<$l{_3Uxt267Q&FveMN-QWCb@aL^$y&EvC{0v(2+8SQFmt7Q(lc33Qe74jb7;1Wgh8)LJB)wq1|!u1pu<4`iI8t_KYNUK=I>aCfW!PnaMV!j~Y zj)e`sO!qUF%GMg?UzlZ&KJX%l)E1`LX7V{NX64EjmE0WPR^g{J3m@(aP-T*cM~fIq^_Ao{+5~G0I8${~ z%EsJ@M@SFI?2tRxezLO~$I|dyy%?NZzmMLSMaDZy$TVUJiu`5r$-!bv$If_BC`hNU zR1@fPFyg4mdw-}z+lM6dhtbWgKlH0&)Cg)zj@LFfx^2=o@O+M`^*fag{x)%b5g;BG z^F5(NB2&^}<9y%6?x=@}n%Ah2|FheYXHlXE$9AGRvs8Uv7@0$5e))F`h8MWhS%y2a z3fl9{q+VCsv?n32q70R3&1-gI04=9lTZ>##D81dx9kH2R-u>n_?6(z*e0}u~@}x0m z)z~R39(nC%S5g44jh2bGl)Em~%drpnc6*m#_xZyU9dxi^k9Kj9Dr{vA@h zX!Q}_TL7Jo+ZczPp54+|`5|a~y)L@Cvx95u-M4Kfy#c@{K>zG9SI*!a`Q0o5ow?je zI_Rl?xO&6;K!YDv^T1+`mvL^aWbb#!OiTZRxSXNjR#&x={};`Wl7)I&kb4}vLs~Bp zD}ip=UxcRSomFQA$s&(b_}P)|zw5JcOGh|0&(y#Pi+l%B;B&=&1Q1q0@ZhsEY~_kH z@x+kMCDktFc9u{Q1Ay@oAW_2ThzQXE_s3p2X2A~u19mfpl89T~%x#oz>@v!{10;o4Y2Z%HxjW-VDvCTqu9{eOVwt&i+)R+; z-YfLnNO@9M@CvrHm|ED|+_@Dh>yuqStxA4wKmj~#R!jBsx@wbw!(;Lp`GKS(f)b_+ zDM8v|Y7Vj!ZKbVuQ*4mjv@|2SR>-BU&OQP5lc=%GSgEa7%e&K~G>_~I?p??}R~OppZ{k0^ zZ7gZ}>2*&n-X$~gqpcR^OtKJvW_=>n&CYTavnU@4mpVEnsX;zkiHg=NnJv0#@MFkU zat&U%YqJOCPpPP}I?qCLaa%`w zT6Yfat#?(Y+L?CX1V!^EVWu!jy&p^=-AZ_*-*{3Ze3?EO|`cd)7T z;N}3hT{q+^4h1UJrG`>_1dSkkMTcTgiyCy*CyRCc>`darcrN?*M;F`ur-cPWtoS@P zqe1r;OrRg6Vm=y?EA^l?7ys0VB!*Z#q?Ajy@S|R&RqXhrSpSJWV4-I8GK>c~PXDF? z^tVxa=9~x>3zR%TY2A3J;jkmLA-V_qs%j3)pCYMRHw;*Qq;%5xKG2wf=W$7ZVtVjyTS)2DXS7CmluXBM_zsL3vd@rz$rz3fTtP-V{qXjrQGOJ(_#Rf&&g>GaXlnMGrpyrNh>;J8nTEzih{gj?< z($U`jL6Q7U`6F9C89G0K@TGQ>C+&yLl4w#&`oQlA35)nnmLCT{H_o)PhbPx*Mv(8{ z*>#>t)~&jnGnw3B?4<+23YW*@kH!7&a9m|PC0aT&MY6GOn*|r*m&PR{ecbp4H+F`l z1RtEOQqr^g%f_+6Q3y7k<@Mz~@R&!2fYvufXwqGQTkOU5?JCZnO-i~|b8(8xWG~23gs0i#>iv*b z{Tou(su6=hM%P=#8K<5*`;*z$g>O4(s9u(HlomDnQ6`5B*S@{@z;1KhsFdMsOn2b=B?ZB!$7WsQx&hN}H-ZA+~YJ23( zg_a3w2Ce@MK1i(V>UL3K>PbX za%!#MQ#B<2Pq{l-*P+*U^Z4#u4U2+R#vVEEH&$;s+TUhu)m5yu^2>Lusda|}-PqC4 zY;Rh2b*rs6A#O`o2pwE_+9-R0a#`X*%B95}viCx9amPQ!>E;hXbZ;#{M72y05vbh` z-c!v8Ebg@u@CyhhHf8A&Wj)8|)$ZnHttWaNDrhAi!Op#yflie+ThTR{TWH@hT5jI1 zf_#P6h>Iph63YtN`4A8;{k=IG|FaxB(6QkUB#}2gTFNMXrSF;cT!QQj_YgBxCi9s> z4xzO0dqXT@x#U(`ovbbh*}27_E!B|Lets45bg%bXE=x4QyWFLF>0?#kUsD2ef+9mV z9P%=XnJFTY(Gn;~xS6dP8ppcTf+riPJg2H#Y0+g?h(S?IUmNS61b_a;;$%r{d%77t zkxMI;(pX0>+g{JD*_!tec_RVxhD`WpUU{{qvPR<7XuouI%`UMVgXV8Rbmgx+bDzgb znan1nFes!jPwtQ33HhR$Xb6C&rV{jH^weGQ23^?4o}X@4xjpAWw|4-?!d6>Yd4O71 zsvnnLyX#`mnzBHpv}282-&Ly(Woh9%&&G=~T@@WW^bl8QRVM z*efnZ(?rZv7FqhYcUpw$4a5xA^S`GK4rb(hU{Q+=-g|~XlFFw5IS@XVi*`$0H}dQH zwkk7uN!ZZTpIRw43!WKIJ?UKKKO0@oRAw)58C)v9$B?*G^!3^&ToCPQX-tk~Ajfgk z7I4tGFcKP^S(6&AE~B`Pn|XbR_>m4cp%#gl%+8ESj3&8@(3}XEwopD>r0YVR|3`Ii z0TkEzeTkBw3BiH}4G@Bppba5-(BK-}gEtPrg9ex2?ry;vhXBFdLpSd3-pCy8@7{Ow zpEor%Q#Dhs9#yI0RGL1g`+VO%Ywx|*+HJ>Yu(h6E^zW=PiiO9Tlrz`ULcl*tb?%CY z`AyC(HZ3Y~w0zK&TzYdPAcy%l>aLy(UVE-sWh_7e5<7!o#Q_%~G0EniBU8@$omqsT zP_^ubv~HcMwIyR*U%B_ru_J7)FAT=!Y$;coXE|XTctc8EG*POh*y^6l4O|S}9lzm7 ze$5?4({$U)MvffZm6*k#q^rpI>CU?=NEt>^?9)RhKq99|B?erF1{&UB{T_+hWU_@@ z9io4 zi~M7dEeJw-2u|%?~_QW-8O~D3-YHZtdawh*s(Id)tz$5=)L0fE(O? zl@uG24_YVO**r%(%~qPeUm7s)=RN4OQDvq`w06G%BZ+!{ zzvy2(46=7E33!692+Z5%mKkodh<(5J={JATh36NUEJut@)duCjTH$-}cc^pVJ{DMq1 z5uu)ADY3`fgCu6^N>l%T;wxQ(-el(H-C1X4uBDK8Rz808)vz-g2Y$n34-4uQM}h83 z$#M*!7q!JNBpB>u<0MO5E|Ykw2Ivsdf8FsUSC6SBW|SH$Ezxe*^U~CTX)^I?8`AX4S`1+sq!wVb z+vr&us4Sj*#nY;l@&{}(_TT*Unu|U}*LxW#-{qK7AME#k%!>djM+ep-M-gBx)3@r9 z)#>g!KVA>(Fl6TsjF$~~F&&i=SC_MW=n0e*Cq)ic+F*%~ie5J*`nD|=igUSnq|~cg z(!d`sSRqxv{JlburgV%klDGIql?j;9gpCDY$qyG>K*eNx_7aOZJFPqDFq~aexZ8;)1 z%KYgq&->3!svM6Si1V$Kbv@}9=W?g?kb*@`q#=&qbQF4Qr{md*R z$-Zv5UC8>0hIcgRl8(b}Jet$O=FtAQnO+20{fxjPS9}Mm@yNgL>1>_;ORpOd_n`#x z=c`JS*hSav0T?u7$*LC@G9K5;LAk-&li9bf+~|0=B0jCX8*dgO(>n4VFj)0<&t?Eu zksM>Qew$2h^-^kQ!C~9G@*Bha0g44pYm~QJ13$Y28tBy=im6c-J67 zLvW_SD7+bSi%9!R`DF6lScWMS?SKn))}87`crvBT{)G5DO|x?Z&g?1m<%hR{#^-He zeB#~)MLDfdVgBl?F;1AJp&!&aUIdm9p|bKxaPj$Gz67AT!EYf_M3^-AEf?M{?vB}x zqg)I{ecWZc1#96DrZ#L{P7sH^91-q}eguDEc*eQ+PPo_r`+B)%OY!xTUf00r$)Gkz z>K?WInWA90ZtIXH67ePIm9VZLvL+yFZ=sWSaC)EoV#hL+eP@~{pjtB`E7sdE^b90xw3TvIlXmnCuDBfbNkUM%j0v_wNM0r{H=p?_$>dWFdV>w z2n@Rby`JUvou_96S>y>RE{V5wH{uX;)B5ki5aLfO++D2eB@ZFxZ7s$fmo>D0!RbBG z&P2JA$nE!@^1WKZguQL4zxyl;t{H*W-eVgV`>MQ#(gMBdj3EKO09=RyW=idFhZb;E z*SalGnvHb9+l?H8bO16y>Br+1nULz@%50$8=-Ztmi+%uBNEocwOS7!}GD!gm76e+F zPwMqt4$v=>u2yJNj(=4;Op0w?nccLN1M-6%$HMJFNA=-znH(_Z0rrHRvedch^e4nl zV9J%$5rfPrE46%e`Y#l3Dj(4w_a(rbFBo+46XAfnm4k0r=$QY>+Tnl7Qy6#)d>*fN z7;dct#F2uB%XG#MnGX(yj+Kt@Rfv(EkW0VUX^sQNWJm@>Cu!7a8ov%Q2YzYtq}gGd ztZW9o3~hA2EQnA~Tj2L#z>-Ug8)x6>>2JKqy}6Ek&T)EwhXb%rL_f>M1s*jceiIy3u$oG?EDggKz&$)t;@GG>du zks$P3ljL_$$I6Ax8CMZk_`XOGK6{a>tKQ>yfRW=7O6JERAugLboy>ecKhYh6D`Fip zGDw84_?2YWbq|FH1260c=3J;PyV)#KmSV2Zw`NLTx_`I;l&&2{Z4iT=s==Lve>4DEI+)4+)rOzbRXZ0kNb@`zL4RGw z54qrL=ZU=I=Xn(6*o^)ZmsH!`A+07^pq`dB+)*)6AeK1%`PdPUE~^h&9}iG|Yh0vO zQ0!WZDOd&Vb$iMY2|yw)swmthzFLtApg;0!1fx!&-#g>6EY<_%ID!F4IEn2nk=h&G z4|h?m~dp%k=v<0~_yQWSiS3MdijwEg*56D)&`=VXoz zX(w^kr?&ju2AFsiU_G$X%4Iv@yN}c0adS~pM4+HJSvDWu&b$y#pAD;P0~CZ_d*)E% zI=rD|Q5ekq+2xt@PjcaUGxX`6{o8KGWci@rL|?a>(&Fx7K9h~ADlTDs$|+q{sGnux*mj-Irg>x=f$X&x(dQ=|Q58qnk-uEz0^Y=JT6@ zEeL2x-Zt=&9P*&OQSdiarXv<2VYvPNT6QrT{dgKFxQi&@>;4dmw1KaAXV)IQFPv|F zCEa*1LF|sl?s%O2j*u>^h}}#5AcS7;{Rd^zB;Z~%`l$Y}rdAfX<2MP72r^E2Xrd5~ z2;#g|=xv#w7b1JynL#Ps12|u;<;V2WsH7$xU$)cm@L80VqO-L(5`wHH5_ZDNNClnWiJUoUFtJx2A)pn(%>C3n5MNbVsACsTA0~#IY!@%9G zc}-7db!kSnBOZ;|ded;fFdXf0xipq|)za9*qh+ZIbtOyf!p!CLomUb+tSxM;L;&#f z)eC{zsKIk5jVW?1?RPK+?`mhr^jM(_M(J*w<#0jE+oE%XIp;^92=BIvUu$-D3yRIHx3cV4uu-k<{|Tok9-2Y zBinDDXOeIfE&OctHUPM()%_GjB#N&kGa^U~vu73(k}`{)f*Ob>_$&fqpk)1jp^l1s z?19Y3h=#m8xRHVt%1bGV^|o>pvEJtcd#mX{7|{{ithKmjEtS&Xs%a(HCH~D_uy0pA zgrcmh-QfDM7;vv~FI~!M1}H?VaSOZY!e zsQwXoU(Q$foR=?cFw~K&!N@@Oou=H$)nXuz{4YqRMRj(Og4?EN*i*d}C4eTXMLkPA z+L-(5tqisSe_kkd-yx@WmaZ4-uKu&1fX&PnBun!MXlXlAy%qP)Y8$=UEeQ)f>t;0* zn5XKh`o(YSB>N8PWHJl_R6T&o}{M{NT zM(hkegZ3VcZ#eoyu$1z@>E?IZZc;8f@WA1zaEqO-hYJR)o!uEmunYsh_?ye&=2oTt z4jAaEDU)GtLKPc1d5u$HX%?o|^>*vSRhe z(iQYS5G?zrZwe14tHH*pl&~9hXqn;Xo&aXRG8(YkajlvBiW31hc}P-#W%kJM`TXz7 zIj;T_-|X(S&C6b5%*U?<+e-QhR^3hxqjo8{mC4h2@*^mlI(B;!Xl8D=Rc02v3R*C$ z#Kont#gF?_5ypO>_xa>qrJ&&ps>bYz)I#e~j#bXY-5RpUCjFOWtIY7?LfW9L@udP!k#J7KozC zN>diR5{(4ePY-8K;>Q3hvpxO*5(`1+X8avz)1Ti+WDSL}?@@x$I#9w>i$PdK5l=vg z1wKN#{(G9YGqc2Jq=rXivb%kKG(u?M~8H4&iKbTR=gIG}LI|1u)qybgLo z`BnkroMN63l%6T_ZOlpZLcreU2~jjIZeDef?r5WS*YLDH(ttPBxg6k^mGi}WR|C*+ z&Wof<;!{#~k^dD&3VwF5`fOy7$@ltw#nsgUZF4IZ6XKxl@z`k5G98r)<4p1lrCBR4 z=-uG6X&t`ZKmd;ZF44m;j!3?5Ei-@X~nP)rR zWwBx)kCc_6sd0CHZXfd&Ku13H)GD+i_|T&5z-t?_ZEnRRo$khdM(Lgo z__Ty0Za(BZ{DzfS3s5wDiYba|WQQQJ-@YC`tXiX@^D{hKrm$4Lg*qoH5q7 zsZe9qb6kB!9AuVOpg`q%zwD1}Vj50}IDOS{$pOOp3u<2jY^mnR0(7;A3$Kqpnf*54~-g<=OqS_~WTxYC@&digS` z#;Dn;t-Yp6x?=X-Bh-w3qhbAVheKPzGfFVQuJyLU($}_o1tH&L{?Jjhx<_IopaKir zX4{HyPh@QD^;`5=TdukV_HA(kNeDtm{gUhTaS6>;P{Ea~pQ*t`VXb@xu7wNWNYyk@xmBS1=>cJ3f$yBaxzI}An=ox%C+zE_CgTSt}P|l^oel&0$^pq4h)w_&hV!am-ufKJ* z+5zgZZqD0i zUfVfI&xjPboR4uH2#!{#BY0E2)@tM8eTR~AOZLMAY#|-vv+a!3+EucO(mG_a0PR7m zwjrhZgqre(Tih1R%}YyW)mXLRTR%7m+uZ$PHxbv8Do4S(-V*I$-6ET~YfZM+uUaFIsjDNN8IjSFmEHM4SduhoR9L1OC(y0>`(Y(+_iWlqDYe`8o zzTRx%@6vinb^K8eHTZN7y+rWn<08db%WXChsISgj^g^4d;($B* zZH;`cU*t3IS4F)x9qg9NkDDbq)VgOxVyTOQLxUyUyz9%pyqPsh>^`{G_zgyKTe`V2 zE@rP&|Aok6gm&gSjxIz}}h`af|={lRO0sH_`(SduRxm)c2Db zoYHR>c@?^{u;OyG&1|=M$wQ%!#H}gUx=3_%xwy5W@zwY;3prM0eFdePx~yIdWZ}mB z+E)UbVrOYL;aV{~{E-O&-XqTd+(hM zIo)%&gB*})=eGo8?}Q!s_h(7YS5=?}d?m&D+)@)g6JH&cy@&Nowb@$m|IseqkQUv` z=YCngF?LzL)1vk0P2lne9J>?rsc?{q5IgVF?uP}BT89c0g{I$MY?PmV9!ua%?2%dr z_Zu<$QEZ-vJ{5Q9&an=u?`KA4ac~yPWqd7|c|iCd_V!;YOF+nQbD*Dbd5=C+_P9<*hdo8KN8{9|q>@xn-s8#}N3d!>f+T{$duO$;0%lKyD(YvoL8X9-&gL!OCoGK?noGKVK^LB0 zBYgMHMyuT)gBf7dL@{kCNo=fhT0ThPZcYCrpeqPkEx3IGoPrpEnTtP|;R@~_Ve7$y ztS`mnG#LOzV;n!(HZ0Dg)D0_r)5*zNTO2T^rE-nvItG)@DikPSc}a4YfW z+j_XA0jA-&pYK_KAs^8aI31E(?e&)0iholBB8x%$5(SZzuXMwoDYk0R+oC`JP~0fL z=Xf(f^JIJd^$c5H4B(cqeR3U^#Jr_M8?_L;`Wz4*|3kA_V0X(?9;;U6c|trPBl|bi z1Ynl-r1RcKZLdB3IAh>q8Fsn>nEJSFQ9Ni+fp$&I-!y}NNG^L2Vs;HYdVu7D*O%IR z{;mV&e2{CKpQ%`>hdI*(&uDTvk+n1V$aq#9H+JTJc3mo+*eVsCQui`VN9Td)y7jv{ z3CKI-0%SDkqEVb94s9B1W**}DTe$PpZRW(Q!&-XzN0 z7>1bfX|CDI9g1$4(_Qd@_Ik6GtzmbhtBRDlN;)-A28s@YU2JXKk*?61O&1j15Wk5_ zhhco2vWy*Uyc?Zf?~l?M(y3ejAw2q}9N?yG-F<-%i7>?W0Y1h7(;kQOE555eF&Y}6 z0y;1l+yZ_R{sQjQv7y`OqQ(z!CpwSoLSKiCDiB+wAe@yZti zaJ;)Vpld(P{!_VqY9;i0ItwllL`0c_q6V^Fh~Gk=mV$Sw(1@>3jJYhn!tgg&VPdI0 z;ExQlSCwk)Qh8;v&T-uYm}E+(pPzAP{kr_3(vKJS9J$IuBKn`6a#Ew>9gnLRl&h$S zM8FmL+~Z)~v9BNS&@%?ebl=&w$`vb&_=9fkIf9XP*Dv&3rM_n9IL@x13wO z!>)7uS&sI>{UdsVrT_b+X0AoQ2C>+T2$3q#&;|(fKGkNQ)XDi>8KO5zUJTOdZ@0-P&6 z!w5ASO|lu;g+YGyEj9W{t3muQ2w}O-U#(=)!G~7z+x`EvlHULWyzZ)1=4I2GnE=Pb zXy9Y@4zp(UHLa1XjVd`O@;8N~!9t&DLP^xrq;0X9H2YNf%Q)xpm)RpSXbY0TQ|G*UnRzzS#J+Rj{ATYdpcmTDf#7RSsgS>1d;`W-nGGtTgz8vs9 zk*@?G)n{hvtywLE!cg-;I8gg>@$j<`haR3-!9V)e0FHa^uCWpwm?8kX?mI+p{26|D zLG`T1VggDsV;)uv;MJ4@b}nBUrvZq=V=Y0>Js}Jo+k0o7V-84tr0CzqSMN)Lg^+AU24XX3&W}hi>D0UZ`65 zV^LCy0-aO9cEX(Fpg29^2T2;A7d=2&i;j_eF46sY(u40Dc>H-H{h_1@Yym1GWRp+X z;BT=X;S00-nUK^T!~@nv=Cz;Rqef726e$(fy?6AYd?@a-G<AY+V zutl-7Z%cW>nPNW`ux>^2_H(%EQ(`ORFZ>O%$+WaCHxt@oGTKyx<@1%yz7I}wa5?WR zYgiZp_Rd%A5xv%-bJ809wb#wnif`Szl&{@7!bNIG*zfEiz;}vcWho8um9Lgfegy4$ zveJFWB<}zz2P!k?-OA%E9l*>XCdl3#U`_{_8>7GFGy)LJb<`gyhw2)lRcEK<`YiSJ zN%zH6#rAH!fEWFPf1mB16#fuBr3lir-fOqK31Z9_@^b+!Ex0E4Wh*FvH`OQssS3TO z1OsTluxitQOPgJMDI$@_L9;?XUzLYA*6Qp2)JUnyp4`VKo~IqVzMOuhM+V(7ac2ys zwbt(gzJeVVrmkn#=ft+T{TR7+BO78rZhrZyM-i&OZs4Fo4sA|Ul_tjZda3t8C9{f) z!_ZPP;Kg}6_rifj!0y-wEWVqdU`3he^|%x4o4@($m6!@mdwtO$e|Zs-xYed+ zMgmClT|NvBR41!E@PR71_9@^in3XQ_5ZD0|jRzx~;;=tADCFWtD$h zhDlsKcga=g&Q#2H$H6c>C*@`V#=8GdNv4z#aU|@jJ2^d#kwv7H%;V_Iu^nXN_n}sv zn`?jzHq;g|M^X7j1KO0{Qr-(=<#bid4vH$n{5O4oq5xn6U4Q3Lcuy9OH5b8pu2bPo zBytoii#gsPMAIA1oVw?@NcXG?Yf4@_0pKKJ2yxC53+U2Y)-3XwR~*KBw9*jVIxge_wz0p+66d0`lO=L13qPIG8XXw zQB-l;gk)rma5D5ycd&s6_eSsv!15T)AcVD=o)J8#E5=!dAB@#zf~xi~Br*bv#vf`p zk~VL}UNv~aA?1&W>9W`e4B4CjQ(Dhp(xdUJhy5x2U-l;-TIt*DBVTbj$F=XY;k4Qs z6W?w0Mfz{>la1!$nj1*HXNp1AoWF~-3+q(BJx~PGTEFB*(8^+qcPoVg=Ip?;O_p{* z(8f>jsXkUi$>Y~gx5k?C0>ly~j4yNiWf4U~C4X1kBc<{Ytt`gUCuyGF*b9GVRxgkJ z+~$iM&DLqRV?_U1j3_|<=?Zf|Zy2y~J>VBk>SKx~3x)B!4wi#uxiNXF>0Dn4L)_bWe{BskHq2C7; z>vQq^2}Thmj)On?%B9DR4@28xo6j}c6n4WEMCZ=m1|!qd?@(U=eeY%kP}@A<%CQVr zGYk-`k`ugT#jz5L+UBGfgUpJgec(r?h~!AdJ&D{XjsrR-*}GN3LB&uHA4hbuJ`?AQ z^0n#nYr^xeZd5r~#aRu!k9=y_z2pI`2mXnwMEt*)E% zV=Ke@Zrhs;`Spsx0j(44EVdX*#Q#24>7E246&5K&!b$X6!4bF z*`>)7X8 z#wLZ(D7z2UUx$K+*m$?pbgp9ZNC+=KKgAL#SJ|m$u-cB1O1=gkfUf4Nhhz5Vl^NhR z`h#h;A7COE1=`~q*4r27m7R~f%cgJ2)wXLFu(tiT<&eVXn7lTiB(?qw zuT%r=jQfeW*E<5310+v| zsJ*w@bq#~-l{xK0vs+S=9xerBx5sWWY--Abf>65}W5CAP|&Eeh^Yd5*iio$udC_ML;l!Ylh$F$7#>2LI$=@cUlk`bdQDJ z2vy7%H=my3(x~L#bnH8>W)R`dNt`w+0A~Z*u#E)?$7MzhHcLJi8E6OyV1a+{L7+P1 zwpw$CYfpYMICmna10mMEU{S?)dG@ORbj!#1DX+V9;#mGvNDTv{_|&s~t(|?5Go!0- zDn+J37p_~uj!CS}nE|Jkx$)4J!Vd9p`d6<$(0`|yD)8K6c@oSna3uv3ac;y32FA-x zM>?An%uO?|=d zyWc?T+}s~#(rs3VHt{U;Ky8oV`{uB)mujhySCMY@2O4uuEggDCn&jXzfUdS!t_O@^tr>2E)EbNQ<8mBx|>Q+zl2djw(bRiz=yT#qZd zq9eA0*hJ^xgzn9X(VvILb27DSOb#A7Zt@8mOU;%a=aM_@aEM_B8LiNFhfyK)d1LnS>Em($+z;OC}_ z=0>H%5xmA`3rnNgAvWL)T2)M+jfNF#p&?Wt%<*(~k^enIxA5Usgu>)FfYwgbb@Vwb3`|nPep&yXR(b>)V*n z$i@h6ywMstk!dWJt?5=-8qF%2`cbt#S&$)4er=z#1cVt=Xo`Ole?z_-*~}SAr+PZe z)a=#AIcE($b|>y%1W>e80a0a#GN3kp#5@FCY`)cCyK<>W(e2C^FS+pp{!%!av^t2_sTi^i8RoV_5v4yzfaBxfaa)hm@2$_$#bqH~4z z1a(`-lMJy=JHi?g)h1@cy<*y8m?;Pdi|Hcrrx6*OFCHRk0%JqQ@4^E*P%K2fxyaj) z>*-+L7SxoJ3cLm13(aZU-+Z4+{`N0Z01e zr}LS^5};IG{r1bETWOa`bEph{AKqlcATLJ&oYDK@@&9`zXNhvcQaTLMN;6GKf(A5C zQv=S-vM1=3>gY!SQM_Wlnoi{4ZjD*B$xwVCUFk8)cIt=&=SHVW8&E?%oR()Obge3_VnZ8&-A^oXUWYI8CX=zE~n2!5}E){C0 z#aw#ls3beTbL(JPBCC}-nSd?Z+u*mmBMO9TPJ1)OfKQcKcNyQ)+qK%CSIz=AA9R`S z=EL4MQOlAxYvl(^;WIi6T2MUWUG_{b#d*O>b|WPwlTjpPA z0xm1(_0d}xy@9xSh&KT;b(ygj2;N8`VB6OWp)*&SrJnL7<=#dt)o4^v5_3?KDES0l ziHpKD0Y+zh2K(jsk66c*JCd~g3c`hXpdcV15&jz%!B$)hd%p?=V(5f!81EvpyJhRl z+M=fCLiF@^Zm!TlG)(F#ydLYjDhC}C9%~pX#mZuG{HjzoObzg=1*hF#^fUm5wHf%` z^Bz{$ndh9}z*`TW53qt4eTlkptsheIzNjCj5ZDqk5JRd(Q)`4&Y}n<(>t6u-oy7L^ zQnEJjqXS8|R@m?YDgr`T*uS_C4MmfEbXk9O)Y)sg{?Mjoi(k2QpLY}3u&EF_GwNsK zISq`5{0P8K5B%eg0ww|E=zKm9zjt%vJVVzQ1`|Gm-@PTouQD05885>Cq6F^0pB+G- zUl%FquEpg~Lmw~_@bkX`RUclCM*i>WdpK_UHv~N#zWjm~e20U_M@{$m;Sd1{>wo#= zfi9}0u%c&X2MT?S-?i=R0r{??&*f1JtG4+cAQ*9@e0^ulqS|rejrrwGw6|fM%N@6e z7@*adXt}*t%5x};r4=XW3@3ZeZOsKnSCJ^eUh9h+ZRRH=F9Y^9qmxA7(}j2?N=mQh z`;jO^X#I|wGbsYXtmnVijeRm5vSs|tP zcl-@pjiu7XzD1BbD`o9nrc4i7Rf6hri}##QEb6tfpN&Yt{(5W6dE|_=XQGd zwGT_zhjfd4^~Gbj&gRTjTay|E*q!g#JR0;8+c7DW@=goS3`+a@qBwndr*rR#jE8K> zr@*si&F~Hf-TnIl@EL8)-MsSt%|k+BcKzOef40((sAWLR0VU{B$q-icuep#OMAs=5qvu zq*cbU`9Km@mtm@u!x+n4mgRq9K18@++8L3|VJkt{8RC|om{J5VJVK=()l(BG8OZ7e6&NCE8{uRNWm@@& z`r@au<*LCHZ@`Nl1bWn{ow6R{TWK-$c;ucs-Lfgl@oGDT#?!SbyLavn32;a{5V~rq z|J-2&cpby()2YtC>Q~n-Pa-NK@amc9G~Q~}S*qwkODYtcA^f@@j z?*1UIrN|&JZ_YtFTHdZn>zFgG2fp#f6GSm-)uuYz$@(>PVvf&6Q&!N-^Fi~*QQs&K zR{d1WMj2ukgXFTGIqZ_>;(E8<{@U~G_1f?)1qLaPXz%w|PNPGgJ{8Y3ebaF-s{|g5 zSI6r;S$St^wj0bovbP71J-92sg+!dYwl59^<1@zbT4#>s;cLdE+#Lyrk&n!ifdIg~ z^LTkBm%?tEIg&ORNMoLJQ_MNP2nnWFdevx|jc-U(_*D>euW3LGQUlJ-bLlBO3TgVM3*=jN%@r@Uep&-KyB77&FK?QC zjmJ)&lnvbADS(~FPey_|&%4|lQ4OzMKonx#CJvq?c5=#IwiMSVp(o7!Pq&KjKO%>V+H82M&c*r{3DVxxW`B!m8m?*G=iU2Q^?KWYTi7cTS3l!1DG8uO8)mYVjjg^#v!ruG@ z{p3oib1avP6lby8*>d9=0OqQf1Ec)ZLfpAh;Z>pwW&qG86rcxTyi@S`a&6H2l=;r7 zM(oszgqu)(Vn=7|cNmhWCVGYJKr~$bZ>6G)*Ge%hVLRrHl3%q%`oo)qJVSs%4-+wEvTXIf|iz+TuY~I@Q2dJ!&0ik zGn`oxte!y0$)utf~;0v9x;vF$*o_BeDg5O6QVF zWfQHA8s^e88($$)p}+sZ5X)EwDD@j^h^>5+RwZ#8HsGf4(0k*swx-oC(VgcKc2u~%sQl=215cq)Xg$A7B!p)aMU9R0n?Lg*Z zTwg0{kg4+yp+ns#@g)u$mZhC}Z{BCOU#tPYQ{$nVO|ppb!&0Hb+P?~fU^TA0F%fe~ z{#?yGc0a2I^>(37L%##XL$V2W|JfCmVZR=#Y8INaA={fr(O|wF8JJW3w9}j1U;^dr zcPezP*#5tFa(>^e zTP!4mGPRpJ=go<&D(`9FTp-+gUBsVZ$1@)lb0tW>ge3!;CUN-h4CL#_e`DwWFFy4x Zg>Tpg-MB{tbq|*-DJuJ+Ojyt7zX7}QNp=7L literal 199319 zcmeFZWmHt}+drxzDk`9)AR*G-AVW$?cc;YAA)NyvB@F{ZclSs)C`gxdgLDqf(9GF< zf8XD8-kg`uIcuG@{=C|253^_XzOVbfKXpxrlENp<=fuw+Ja~X9EhVn<-~l?`g9ndm zo;?B1%#LUhK6vo%fwcHXHFtx(1@t&v^YiYbu%;${37MGeJf%{p7~{?wDF-+DeWv~W zeU{FJvUP5bJq^Vsx;gVJmRufBwFR zy?P|^@A3Y7rN{p{{LAw{C+#Tz&&dZ5{=a-6ox?vzRtwe_{ezJ)RI+%6IKQK;mrw=z zt)BWwQV>m@-4aZG!?q)cyf4-fm~kx*@;mfSQ9$sY!M`AU zuXOWr+;&nLNe(h2m-e59<+)BcB5!^B4rqsadX6~3!w(O>3<}HdPDESb%g98(`?v>2 zWu#rNR-Te^^fm`1<=pQLR{aCuvN-cJc2Iw#~~{f*Ds2kR=7R$ zJsOGe!({jOl7uEy(e6L|t1tT_;PyA)(6Q*XYItIjtRrW7Qy80^ZKuTF2Vk4CUK@rd zv#Ao4YgsSBwHg!0%aN9%lj?1@3F!U2iJOSIhKk?eWD}coLSJ~5s>h2}R2mEI^twD%L%Q{#Qa1kO_S&zU?nfnkNt@DZPRZmy~oJzhgceL0@{W_6^?@4G2i0icO({gI3HF#lTVFP*YQ7PT25(t_6 z+ZHPF`u@QjqDcK#%zrM3D0>mxEmSC$>$OoRrVGFl*|TLw#W8X}-`P`pj?-`$$^jX|A>uO%YLVOivhi?XPUcVMM+8{r z{7ChztF^s*oF3XYfZ>n0j>jz+?rnIHu(lT%=D0mvg2QTOBb_7=k}H)gHJUGDbZ#i% zd>W7{m^vi5f3Rq{UBC>yt(8Jmy9 zWlTQZ`O{-Q_!NXO5Ru|G_?RI}EHGM&JzF}i=QZL?22CLkPHp=AuaN9*C0Rl2ug5P4 z)C^iZZTGDwER+ZZStr?V^O29M3wT=MBPdO?QVNNDlJ_ey&*Kz~jD- z;3#VQ5C){y#ZYY#BeaNPVPxWs>Mv1*C@0`X}9X70DZ_Tq01zYpXeTCcKA*z{Xp`|NjuAvefN1{ENv?t+A(zo4 z>bl2o%vxnS6s_Y^t~QFbTuSkm*gb-DdgL)U@|5qaVdAIVAeEKr((>7FBH6u7q8WHv z00wCvP&$+}LookOib-?&b2snBx(DO0vB{FI^=EJ1hpcqo$#$2Lobwg?dTG}i`)9N! zEVl>7Sb@W2;^iz6U;<1^b7nnCT{lv$&Bd5jVQz+awr+5P%Vv7@F8lmEi#Ao1fakFs z&%v~P?S$2o>L$z8OjMpuLpGGw-0WKDesHJC7wS!PWLtbrbU1u20~^d7a|6-QKFP;g zt%u6aPmc_`bjIMCuP^7&LO~M7!nTFZw;@%&JtyX@R34)N&<}nG8p=c0P2OkuB>XN4 z-=1Jt(JX}{{@lqdcpWMsNj|VBQ+|p*HS}37D183|K`!h;^E4_A`Y9@1YF4e6$}-y> z+4ytIDMR2SSitvAMTJWC&F8riU|ROTcI)=88AR*ZpbQc?*LNp`tm}VAVvqM`WP6k8 z%i!%~n$E_5I0;!~s|#bO#uAFCyxk8-mj70a9ZwcacPJOHg;0MRHeu3P%gMPZJ*PLm z_yXl}5KyhoI-R}T3aQ#?7LOvHVRFhvrb@oJ7soDP0f zZq$_tS>J9>RJ9b**ouHB(&LXuV=?4H1wkS%zOhTR7F zIy|M+uzuK&gQjf!Z;?RhnuyYt>6K`eBp4UgbG38EuWU zUuuE*Q5K|JM^>rtvSX5Q%K8YOWwqs0tx4k~rbiLK2dY7KgGJvw-pvA8rENa)y!ook zYwlbh-GsU6C68~eiDcA!2tRK{0zWa2Pc6yaZFoajRFlhQ>oD=$7ioiC(D9`OxceYHsBb>Fiwgy)H8&8r*-HzQ3*w5OsX{mk1h zD&&nKJZ~iApaNd)3`ejjmbBzU@xZh?|b- z$)x#s@@T5q$u|wmVwq%C=xqPQ)u5Lta)o=NzO;LrJKZ46>U-gocG`6$6h4|8ZPun% zSyxrP5BlxR(uKKWD!Kp5=^36d+C%ivat7q_+5kguEKNKlII1_sWRW5keEhxD%-!sI z9}NLsZV!qXWHg(Bfv*_FhD2L~)rQkQh#RzeQ*#r0KWiTE@za+>{caOuWJswwC)>B~ zy-+8XhSK@%O+!TN7F$hNHFH`T`(f5o;MGY%$DMhP247TGi$btXfz19b^<0Czn8)o7 zOs#mVed>!fsbt#uT`{vCSD@x z5YEF7*5>e`HxdixXv1OAX;@|@t#Vu}xKn0HMZN7!WP#0l9ZI<#@Ed2C>rYjQp@qC2 zY;yV9YJRm?^z3zeFZQKlvhXpl-4gfQHmi-yu*#sVNcWt zJTa%aEZ$C*)adJP)(a!*GS26ij#Wb*9q-?IZL@z)4R}sE8t~<6>1xpTj6*#d(<+`5 z=FAN8lCG6AlsS9eIoSGW{tlsQx!_U=&=O7J8@(ZtrBQBO)63a1j%LLa5jY_TNe1BE z&gdZF1WuSon5HchSls7w1{yI*%*%CmHJEM5;FOJAdnI}sV(vRqQyCMxf652t*a|48 zPLm6K6Y|Yak=IQQyXDSX+@d?sIv zeXfV)qt-3JTG&^$2_LW!WK>e~_o_Zs=e9(z3>6tUxt||*=%MHPg3*uiis$-a+|}Ba z`07)w9S}@fmT207o7w!BwY#kB7P-MU{C{4e)SJ%98k98)kC&f2m+Frs zFCH8(zQmP}0)+`}mR|M`CS>?>Kwy%i`3e-aO~{0^6rocmo4M-pI9C1f`N`WGwVVq0 zPA9{d#j4R~aE7GyIAeQ#-;^dUP+kn4J&AxtGpLp7sjaYKQafgVa-d%!=& z>V5l7Q-?WU-Q*5~NvRq2hyRUUrDs)(9Ig9^3^zE?_v%x&YS^q#-wtW;?qrdQOr^O3 zZN%I;=UhE8()(@&f17*S>zP-K_fn6du+IU9lJnl{qU-IMRzLWif1~fMV{Ope-IJ}= zQlmK?^uo56+WAA;EV{0cLg$I89K(4H!}gZo$o@q77Xt{Mh>{ejt0piHx|Nh}7ff8S z@bm{O5n7j)f^cM|&6sVWc0Rm7u|U%GkA;J`Z)vY937W)0xn}Sa*RyO&8Zc145AHxE zovH_s5qn1MAf|e4b5`7++pje9{8XUgM{-K}e#v(~;S>p@3_|b`POt@h(yXwIAkOp} zlorRFzEs-gyx!>imTf4&Z1RhawJ$hyo!2(*`Q~*O-cveKhuZT$7)LvkdMpO5EDKK~ zV_VbS$_{KP4|+oGCf?Tee{V$$oSA){yKZj%=qZRx(TwrNAN_?{c}I0L#gD}UU^j=c zs!&zxDk1pY^&}Pb-{m}Yj#i=&>-o#hNiBI=h;%7$EtlI??Z>`!N|q7XoYe4K$d0O! z%+&6lI+&u_xax86^)+arXhM?1j)i^byhp67sW4VA8*5c{9Hy%~9ypQ7T&RVD9I^0V zt+hv-1S#hTfY+NXc@C=YUE4#{Kkw`ce!0lS$iTo8i~>mAU&G9Lo3kg|vs3(1${=S= z##f}4$7Z~`17lLe&m0fLWvB|K$#5u8UIapB@;brM-OQn*^@p27? zNxSKdl1gQS4)2njqwxr9GSj8Z%(=d$T0!xRUS6qQn@9gXX<;?T$dRnoY$`Q}>8a^; zs|mi*XFuaR=!CaAo0W@6?Z7tT+G;D2p2G!ceC-qKIguwRabReoS8-Qg=*itDGa2r4 zy`v2bcAlXbfo6w}lkZ8*J%65`G_+23Jr){9KQT>so_C{xu)J(~@?FOFSMk*tcB@&q zD;ZJ%a>p=Jz0lO1fA(}hJ<|g7wbJ0OI@GzSMa=AzP7SZssDM+GGU7Rueu2=HTzvEA z50A}{$A=>(xb9XSO;$_Q`MFX%3tpqoNnH3fZwZ+rmotz`a$A2=4iCtxR)t=y_b=~8 zrAS%m&P=w$seqXVBYcO#H=Avfv$`4-w2nl^CL`X8mXby4_!Z$s?$DK-THYGO@{J0`=wC%0;h*Yg*iKW(YTCMLrxog< zhlXj`#o_PPCRvZ9)a@sUDEt3*NpbD_A!IvvR^)7Dl0WoQ*0HUymA5!t;oBx44l@dHF-z4a7}9Z~&LHdbc3wM?lzDp0CW)^3H=_?0xwQWk%ZHt=NqM*<%fuEMnhM{oM2EP#x) zV>`A8n~<-^^BYLLqPY4=&^XGF)y0IQ!B$Pr@kpa|>#)g%!W$4$UbYt6EeufM0{ZSL zD}k~;RgI;W-7-S@Jl-pMs?-+hQViD5JVYL*IKD)kV*fA^xps@1amZla)r_w$YNmbe zxbdNAAb_GRIYg_}ATF}a2Q-G`OX^tG4f0W<<)jb8rvs&KZ8|A)CMr6bws3m?JwQa(z(V1g1k?mO1qQPn36r*oJm&HG+V3ryuP^2#Y$ zt5fQvmJsNB9Bx%O?+A|KC229IGtK(9NnhWoG%ML9h-JYh#FV3C;Vx%_pf78=L?D9# zD|)Yzc@7K@yZB!(#gk??|$4j_Iq1kC5Yqgbec6al%F z?l9)1gwp+zsK1lyRqK~wgeryAZn5flVu70qNUT6I0dJ~HnF}mPDkJJN46ZO1*%~Ck zf9|k1UF;V}Z_uM=AMu7)K7aTOtLxFKU1lLHN}~TVU7(9}X>*YNw($x}j&wUA^6uA{ zbb(I%?r2&6M!J~y7p1Q+Z^u2V$n$o&2AoW9v%Vxe(U^qmA-pho+j=Yv+p+{(Xmk0_ zyTRLnrqBlc205eN4E~e`1cOqwD4U!1$IR6)Uv{zg0HxA&qn(?Ei z^q{wDb0aFSSgr8~-o?;$y`IBMRH}LVT~qg7y`r_0KcnhKc{BjZ66XZxc;k-72>S9$ zcn5An#C*=qvFlb{L3@H0f8M!fzEL_c`A1a$doZ#Kop~|UGZMQ=1t>=Oiq|U>DS4v3 z-=LhhE+)7r#eNxHQ5g5m!lHZQtx<;VhxsUGQ*wcpz> zMqBhDUKZ68`@KiRqVP|rjixu5Lr4o|RBc^3yjn#3g+$zt)_lW(g>X*&qnwUB&D+|J zQ{U?Hz*8NAsD2Pn+99>f+A~*JCV|wwkRYHrP8Ty!lZteeOB(PxO>&X9Q2!1(P)rKZ zy^4zY{<>Yd+pOkB&aqhHosQ*}p-Xn9wT-wOhC zt8zYtvcj9YrqT^hr7+27|Jmo1s+9Q+vr_Ttut?Z{oiaM-R#EVe2?a#kqpU6g8!@MyF6gyyt>rZ4$(`%jiH#rJ5UO^FFG|3gYS>ugCddgNWsNzIojT;c zac}>z2&ISUa8iUR46KAIRIb!VIPxkvPw1*#i-3mznx2_g56PF4Bn|GmNT7{2GJ7UZ z#cr3VZD>Y*eLOCv>>Fb_;t>ro^g852j*c0QXxJa_GZ(dqZzt3+YL+i>RQU!zCsG0N zoC9aY(p|QyMvEoN3{pXEfi_1NwWqQZJFHtAN_)bSSpCjRcNOXU>Y=}pFU==wL2oOE zLIpmLJM(#5eKYM%)YzG#vo>Sj7xpd&m}_Ohuwi8hF{xK{JNX)Z3?)#lu zDhTN}{?2*+G|=^H5q{X+8AFnQb&}1TQ_uY5xz=mCN5JZno`uc`(gtTgc0gdrl=h00 z7I*k`6iAgOjv!oB`*zwk=O_D$g`DkZ^KMt2%=;}E$n+8RqosM3s6z~ZEtJ#e|IqrF zV1k~Dl$viqCF;M-zL{HW^cl0pv2FE_c-gmy(sQfgm z{V-D@Ih(my$CZc)5V(0e31{0a?<&vo(G%4D3QrzX-?;pM-01w$YW`x!8K^I=Gt0UJWbPVaeyYXL)mgk?)Ck zy7)7d17EDgFQ)0vo};Y_T*~BSg!Zp?4||gikItA-eVoTOl*80@iPgUrlB>)jkSC`O z!Sy_s|FU6~czPwFx(0a_^DWOi*bUQKGe-2%N&2RKv8{AKyIS-LoAjV+@>XPI;)k{M zG;#@i=C0(YK34Sgkp#EfHb}e4A)%qHBUzWHaHUFe;N>q$8h30k-0+097fKN^ocwc} zJd{SLie@*VE&htvW^T%xkAL{b9s_{UYYGi(mpiUVh4wPQ)^*8-_0`5Ij5xbBJGPIe zJtz95Cglv1=VlFm-}p|9nn|)jSh}rbP>=EG?f_UmZ(e1{(5q>wmvF_8tzXN)4AWrh zia^v2ey#kbQ1WfOBKST2KU-CI-W1Vnn2^IwJq<~-8!C$kheW{r%l3SBt%qD1f#7<= z4tUR+X?Iq3?!BeGsBHL_@_@nB%B4v_tlgTXXBmD(ZJ^vgF91P6P{O5Aj2aD*Ul`V^Qz5ZM4w37nYOvF>l(wm z@x=gx7JjMZxZXYR&S`^i?#B|ARtC&7zW8CXUGr~*3*9^Di&d|5QyJf&J;&qzJdruE zqBpVqVLKeWlx0$Bw;G()2>{Yq673s>A6d?`SK48diE~43N)0$#nHM*mvX+w$nI712 zt1mk={B_jOm`U6^EhdK-m%OS#x$3o+zW-Q{U4fNJxIO(g5NI`xpg3HoZKL!?g^R6H*&ew(Slb5MW!&fWq{20 zce#tor+$tlnG8cdY49`cUP;pqk7(ziWlwDo?_3em?eOi4o98Y}sn9+p<%PuPYg?j+ zMs^G=6Fm+k+aP()K$@=0c2H-Q4JGyR)@m8|7G!!r$))FDm`8vtbo8AgQ?rhct{dKGTj;AdW}T}GfehPT#&)sS{w=pQp^yF-9CxG zNcPhP48I(jfjEq@7ITEUr$K%=@w;BAJGP-gy-mdFF)^ZQQnz3dliGLRegIDEg-sG~ zc>VIkWxoq+pF*1h0$y^t<1t|`qKCqMg%Hz(3SZrSiDV5eXfgp zEo^PkD5tZ6HJc}~#*9ccl9PCsT&&2#IVmgpQBTjT5 zAO!UXE#4lF0}?=^v6dA^?HVV3pK8F$v zPzr)PeP^zCah*g+&^o~-yi$O0(YuaTI@|HK=d2g9|m)GO`{mbwt6}Z7?JP%_S zXKBUS=*q0Be%9mCS-qQYu1c7J1JogIt!IVQVxQkzR8leB-fov2zb{t5RW3~rSg0(C zAF}FKbz-fNyj^BkuoapN2k&RYM(wvgT(?%G&Tw27|8aha zM=s>e1VH{%r+KTpxYzkAM-#hKqs6M^Gp%!8qBy86wmr+7s}zdWasltt-;aG|%vmS3 z!_ih1G7AAG@jb3N6MG$ZBJq3`tcXS}61ViCK~9m zVzE(ar{x1DQLAjWVXKEG6FK^j5W?j^<%AYu9uTaZ~mdPP8&DW zoXcR5*pGMkAB9B5}F%-m~Yv zt{OG!G0eXcCEj%^a9l|ESeF5E@=F!i)gQ^L7B=1Oa#`ytn;R`#`r_r0o~jL2I*3F< z7R-je;rj#`>iT7m8U*6z58lho4FavR6MeD`D#DMotoF5-x2&{E8E1u+QaTwnwNptg zUrm;Ge`&H0J_%rbTZe#kmhjH0hZB&#FM~YSKYO7z8Ez_5vZDuE<{xU*D>;t3=VKY% zi=4TG+RZ})Shnh-I-_|CEbBf@TP5~6oenB*P(gbvdKDiv%FoO9$H<5#OFuuX2ufuH zw$ZDS?!7m3k!L$}fMfzwf9rZNt1+xU;V7Yx)jOHr^CH2XO;CWOBL%kZ<-wu%Bu)?b>%?(V{BgGm2jkZ)Q+HTEPuM7uTi9~mxy>M z-YD{LzdeDyq7aj+{Eb0n`fN!|{H%Gg-eXSgEj-JndAefWC!OepM#^VJ zOLEaoqACG-v2HKGw?5Y}9}C6j)cN{wt2=iSF|_dX_@Y-8<1I}!hGdOq_vQeAOx4c{ z@tb(<7GS{6Bz0<`EazhM`If`wErKz-BUdco84G*11h>%+N~S~GcJ^lH>}|+f#>RyY z#6TfF_&QAL_x|q0xz8i*wQNV;L;aN7&Ef90z>@!jQ6AhJJi|N6KyD1`7R`+|C72E| zZ40LaHC-(IhZ#Ni3v9s`iu_*7irbvdm3&W!AWx1^Z~syJ0N(U5^E;~A$yYDlCB-et zDKj>6i;DEZZMxZ{EomDhe)vkIz^cf%JM5lgJ^0HhlKJfZ08o2u{}Fxe59cNScTNiF zvKq8PRn1xZF2fS2?MmN3H^Opp02g~0>CQ|$d_z%12#?{3u$H>`VYIhS-kj|mC zp*3?30g@~&^D(|r4d;Ke_(%anWpGKv}nGYMtOv+iJ483{o zFrVSw3;7Rzns2XUOyxFBxjWea!xl5=b|4GncJeM+@u_W#*D@y9$psxW;^7Mnq$yJz z-7YHw=t&KqVb%5;yE#uoiEA-4*t!GCY~M~rvqEVrr}#ilchFN(9tp`adsZMyQ=LFA z?4n@CtV0RB{YWa){rnD%h|4nwP^>jWAsMnVg#G65s*CeCg0;U)%w+-Nj7l%@k0TIJ zp;xA>i@TwK566=W_}r-JxR4s#1;4}>O&5Z&wAGeUhTwLcc{~;f{I_nrU8^6#^@o>n zcwJvw&SVzfXEW4tET5fi!rpRZ8|q@b7Pv5Xn%8wBkF#{r>^Wg+&ruF-9N_ z6dAa?mGa?}P+1LIC}6e9!m%Ru!0E0hNd4_;uQ)1sJjL>*MoN#i-aT5Sms{+BOd0*5 z!dR*!qhA`x$(bGjo}J^ZD-r*X93UYyZjQYuDVNGFxZtM|*0MgKhO+MWtr^E|)^+i0yiA%ypYj1k_)wnp>klzL+>8HN3fMWSYE z4hp?v-p7bXko3+kxgFMrr#Fg7VsE|65)ESOXR1j#kG{_#q^BkgLQ1l0mf-So5{v@D z|5GPj@`hAX@TGVbTc4xz>FTG3O=KN%^_mrG6b+uY@p373Dm$92jM^M|FaVhj}2CB2?sF>Cx<6ABvG~9gP22yDvmF=SB_{N+V8U>zl1r*%pOWp0r;uVc2f0y_3im4}G`EsvkpPV&0pZ zr9a@X^Jl)tGZp;2zav=DbnjFGdOkNmvo~fk`Jl!=&y8iOW^M3_;m8}fk%HDr{3T9o%OMM$M}?HtOgsZnJ+rhrC0%xfr1 zPbe0hRs%#1z?oY;6DH1d=i5#RYeNfOm0vm~FIhIHRPMUJAwACkgT`{vBN~IJW>nd7 zxTlfXOJ=MQMg^J06N(C;ef5u61uW?bb$+WxhUqJe#bI`6 zUD8pmXVk`z4?=V#6_Oc&QHt9*V@OeAQ$PN#Ql)}XWp@^oX7=V#3myqL`lOXMXJ$mx zWewT|$!IDlAwBcd1BMNop}|qH3ixDbOz1d z`iLw7u1MDwyhte5bv4_ti0i66xQFJFs#VkfOtK34>U zm`J~D zKE@-^At66m>*dt0Gz-IHP3%{rVPK>x)vc*E9ZnrGXS8pt-lOh(vB^^<&X`6qYkew5 zG@cVE9{J|irt;zA|GBr3o$7e~Cn+;tyXvfzz#dxlL=GN4%l@UXq;b{fU+@@;onBpnMQ3;kg}C zidA!_cvpEVsl7KNrbwuZSr?d5shR<+2E+nH^~^+Z8I{ImBn5{|LvW>PjwRLs&bh*E z)o3bp9*EH=tuZ5ILVoFbd}Ckuiw`E`t0 z>2qRd33#;um~Us*(j=qEBy@NStq#Zm-1Q$MB_s>FK0FL@8r*9kNc$@0&rxEZjukkW z^xfrI`RFn{!dgB0%W`{SuVi(y0xiGtbyR8`yDGu7dH^xbkWRBCu(#XoTOa)H13#Aq zw$&S(62HWtFCgvjSNkb&aQ_h6Xr&MSn6)x3?>6XjxL%9T+`uu3u-!{sHYM?x{`0}Z73ZQ@Y5$D=q^z;AO*n_2aj=BC^c)FSPtym2b2N zd^sv0OMQpc54l^(!+0bX{M?e&DdCBkQAa@h=U+mnuTP`M!j2C-szX`D4?k~c>5Z(8 zXU*;lOfA)zt+Hshf$Ic&!#FWTxqmzFZ5Q4pIBj7e2Dhe5*xz}k8v>dIEs1jtHfDe! z5IR?WFHxQFtLonc>A{_Z^OpVE_vJhd7}UoA78C+`g!wE*i@|6hm6|8GF=zZA4XdeJAodNF^0`OEIaNmDs| z`+#gzG&&hVa|93_J<8;l;*m2dmM?5dCUu&9t%)RQ4}2l;FT;4?{_}f?6OdI^#o_eC z^SIq8oA*7G`fn`p_Kjjn#pfEmo0DQ^E9E%@WWx^6m(*OjcqN8-hMNTJBOsbu>5b6X zo^FnBd6_Qu@5DHa0s1^t8&An8BdlNva)x;v{qd!MwYD$V17sHkkadqu9E^np>|Jt4UdG$8u&?ssrtGLvanSr!2Lg}4fp z{5zN8cxYh^Wm5OPu%ikAlP+uB{(ORI$f_)mgkM{{{lvnE$9+m7Y!ai_;M)Hzx9(5=TkD^Y_w#%L{38G$7SGpvDJ)y17^HE! zVDCPDOFSd)tNDKhaNxOgu}Wiv#(f6d<0caF$@I7sym7Z|Qee7QEum7TryN2^n?GRC ztU)I1!*CBH0HsQLvo(j>JpL5Qt4Y8ISn;KcKg6Ikp_?2^Hgk}~-;qS}#W$z^sRGx* zMTfhySA-#C%#~agW1q6cBQ4tdn$F33)@J(ibS|@jy@MvHZI5@R_58<;N?CQY@3}&k zoGw*Ts~q)xz?Py2>nswVvsjH)Qx5oi8j}K6j)#U589vdLCmMZOCIxxw{tUp#I1vE> z;5a6z=5z0a!N;KqEQY+-Aq0F-Il~_+i!Bh=TOZr^fF{8XN2D+rN&|3O-o8(Qkw+#L zgir0Ua#88COQzl6)5LcNSoKo%UySyqLZYJnMcF^m>=qhm`zLK+5FmE8%WE%(Z*Zv+ z=q(RA1_*=4-3qLT2gvvbR9h*j>|CAz4^c{ANV;{YXq^blU3+v ze7ggtmd&p=OVW_H3N$+Lgh9xtj{#8u_jzPWh1ULp?>h*HrxvY~3_&c_{DFO7RpYjH z;YKmoGb+9Dry4O&xw-+AR{GsX|0icNDd!cEVK}`naWz5V9({Z391cBjnm76_4h8V3 z{BAse8CWV+k%*-Wv;Vid4!a3RxDDtcQ&I&W9~*0m%pWxa&%S2}@@Y_M;ngqyls^^V z+de3dx+8A9WxYn0Glk`JExg)VWlT=%*lEnp%s295s@)PGjL+1d3I)7@z{g}ZEJD+M z-jRszK2rq9w=BTea2uH~+E+;>tz528luXemjbkV{Pgf^{%p3IDT)Ovk>r&WED&|{I ze@KFb+V0wrtkr<8|FG<@BCQ+|hiiQJ*^b4d9*nVs#OGoQG^&lPa+yUPd1|yQb>`0B_VgitqFP9>|+OfD0~R;nfhXGXso;iI2mV+J3PWMqR?2 zqGaRUNf%VSZ=Ye!H1`pFOsUP{PAvR!l=IEsz#=QXs7I!V&6AZ&PH99u;?|gJO$c7 zRL~uze|-LkSU+4!r=(q*Eo)NQxz~eUJAVJR*4a=V2;9ln3B75VGyrJZoNX$3B|M zQSi-%QxM+ z#HxEvdf&~N1XiY$h$EBt;mHbZVlix^Zp&z*yR0&gi>45r?nf0vx)r2rTw4a&ZvaJe zoI!QmV6!hhhc-|0T4*#|lCS7Qm7VB=M`Y<-tKy{mX(joIre)zDqaJs@YK!IoEc`wL zvcyYko_=@-t#t3rl1v7H3u2uCa!T~yEC32!efBgCt$Vm9h8h=DaK5B(vcVLaHAX1a z_ZuS@Ja~-5VEXL-8I|1rJ#yTh#Z2PPtOA}9t)+WnfsQKCbiN;H_LRkvCXz(Z=ux@Q z;I7ns;=^D)pxZG1H78+z>BBTC%Wv+`7EuV%c)lOrB}>z7A}T5}U5GU*T`1O`GwyN9Z-KDQ-Ufwj7Dy{u(Kf|6G?vczgub`DT2Og#LEoD~ zor6J#V)n~!rR@etB7V;GG<-s}y07X>+9XZf;6;c~|NZIu+l+_U4yz5`otQ%SxU#^NAp;C3xYI?9_RaMk1=<=w9dPIhN4=8)qiNn z_Ga8#Tj~}-TbSNGB=uhfb&IgG?eC9~UU@p2OS7i!sB@m%&o|I|l<;!G951Ub%kZ#l zP{b^EFcJ12xAbmgt34B|`pi`qb^9idLw(ns$~Rj+4>66l+JU`*0t0%FfM06RG4iX;Z0dNi$*2__EkMAdG%s&LXu~N|dKDlYQ)> zGo@~Wuj8EH}lD~pW zJLR1X(8|KLmn&b1EX~pHrsNA@304Q1#CI$ zRqt%6q+e++WqnXGHBIDrc_mLYR+Hz!T)=We-@(t#z557sa58BFZ7AXCYyPz$2XE7a z8}=&>Hujw_nZ`@PWRnnXWOw?W;mI_dS1zX$V<)3$x+?H~kp}m(pnw;+I>zD=HU{Ri zrXdgr)+y)Z=HT41dS5Pw%Mmf|A4|5W8VAe}GV02i1RvKY@+qv=fa?Qv#>lL%pvxr| zW}so>r3vfq@10DxJmqlT@z!oL$(S*R;Vh*%Wo<$}r(A8Y50%f?GcQe9(fiNwLfeia1Xuj&T&wwchB zAA0E3fz9-3V5Q~o#}l&kfz!N8y!8K`8jHEL}jQDMli)!6gHPTT4bW^pfI zOAUT8E(;n;qzOE=(b0u=MVZuMY%SI8+aHX?Za6f;GoEjP4v#xG2z&* zd`At&3ysjJwFZ^8_9*CkBLVG7hbEf^U$yv%J!*O!+QXTXr*qh3oWD97gyDT~%;FSj zZDWZr20jKJHpm*oO|+riVhb&$8549~n=|pp@8UJ$g}lIRLEslNcA_U}1cQ4QO=$BZ zB93ur)TD@beH*rqc%4LOasO-`Z97=O@}Fewy6(Za=a9*(6^}YS^;=B)TPg3z>h4XB zytj3-{BQ>v_cG19PaZdWNpjlFk~m!vVC*H#t!C=bx4NSV#bAaRt_NO#CTYr`+HF&3 z*Dk{8(UUJ2ocr*8+~oO zQ&=9~GPW2zU_Jh-N;0_BaU-hVOshh{ox|kPo+y5ZVB_5%GiDRM z(C^FbeWmzs*Lt>{kV5*}RS-DW0jKDs0?9iJlWvZOIYHJ}#JdTok;j9?f1^^`+FwT4 zsaJjb9f7Z}VK+S{?Hk;?=GwzWPXY&vVcab&O;O1Vy|`ToeEvS% z%Wfj5R&Fgaez%4j4@CZ@!k=EZ(@){!5+%}2AlSf()+xUX3S!arf3f%0UsbkkAE$(X zN{FD8fRuE1qjV!6ogyIJ%_c>VQd+t}y1NAd*>v}o?#@kY=H!0wXP$S~`~fp-&0IgR zSa9uqo!6Pi@%?@d*&SG-NZI)ZMMB#6)q!~S_Mu!K@}Cc_>SQ*I@k}>%qn?2rTYAeq zZ{?De>fhe{!#_)@JGw{?&ne2F;*(F6!gXm0tOB-yyXg2)!Rl?n3P5K7HK<(w&&4=d z;*VV0A`ff}vIVAVg76c(D?YSP9k_a_q-NrDQG~El>G>7omAo!zESF}Ub9C!R z=7M*Xcs&^e!}kBPO=;amZVGXWh+cM*;01rBTrn|yj>^y=ki_!=>fZu&n6 zs8|2(4DXF6^IRnhvZ38g5VF@_1U|2P3onrG>_w-3#bzt8jV8gAppu zly}U8v-+d2m4WJE+_;j4*p=(eXKco5Cb&&9QRAlJtEHE845@EC=lo5~_^Pq>^%_hQ zw0#`cxI{a`OfvY)C(yQd91HOW<0dp&C4|vPydkz&?=F!W51WF;BT5Ql#vynqb$ zo7LxYg5=nu=GvfFZhC|_chVe^dX)&(>QPMoIh$3_1#|gHX=l=$XT*IXF-fIb>-JoA zBR?zeZK?fWu_}`tHlBjw{>0?eQ!D9x)X@Av!{amlr0P#`M17^{B#d27X*U}QEGTP7 zRT~JjP^3Ml{DoccQrjV=eRZD44<BHO|UQ@*1QvX&1%~J@YAzv9+fkyXy z9kO80gN4EeLXFPRfHox5?hD)YeS}Zisp2M*Am=MK?AtST?ZOo|dB0$Z{m;SjoE^9| z3GQY%a@aEiIjyFPzdV{XEVCXnTsv<;SLM^>kv;UiCyYHeB|$%w&S{d+q0Y(CZ4_@L z!9`s{+4_zxyS1;hh4$#t78(-x(YEcxH=fAM{2{ZBI@45k1y9CLe#%y+kos%?jGXJaU)D&2v=O6S{OPf5^-tn) zvGOrBF^BEtiAFn;0Fq1*Z1_5c$c74=^y{xL=(EyR##>3LcapUgjBfN*(C|zAQ>gL> zx6dTSaE0{ipg`Z*(a3y8%zYrNpyR8n_~Ca*A!i4V0#UJl+YCyR%oVTKk~R^Nq}9`j zIj=MQjdXBA?Hz4+$NWbJ?nN|RTQO*cvI9ABUcl|ut8i#?CS})t5xH5gQRUHXuU(07 zn`ynnDKKa(AM_C}w+~2f5yo0X#aB*mDu!BOjlC#~5?L{~Y-ArC@xf$5X}vzoob$^X zGp8I5Gwqu*|29f9j<$;uLessE9NHZ&Z1sK6$6Bl$r=#{QKL?l`(znP0Te=l+RK!O3 z4g$ko%WV$Z1*Z3Q_By%KMdu&vgrebxZA=lB|E14xKc1)QxxoVW`N1DH^BZlq)jDR@d zj1cs+@U*6`DFrH2o2Si3_$u=!Jq`sEr3p72Ka!g(?`=1`ck9w|}G)j>} z7Z(|Ym{6fbO}ucaE9Dd_T{BGHK<>%w^{qy#=`ZMMtA#V_3_wEEgAgd=Tt4)B)lbN* zOIJ78%1L&(+$V}fM-0^|>G-%i^rLPPCsIBwRb=eP7F<6!dh|HC?+S5_BE32ndHeE0 zg$p@q@^8ZJ^bd-ZTvc&H&aLp^22|>d5SrWRgONt+OZW+aYUMjGI74SbV_D(-tMEk$ z%{FIt=!g0FEiZ~9PS3u__CF`t?EG|9ZJkkuTR!lvred(%g+S%>wLUVL?O6Gan3J;n zKd*xl4L|S&O6VJ=a+J_+gzZq8Xadh!p{lY=HzRg z3%IoWX}(>S7DQ9W*36yvtx6W1oY~(~S$R;*9^X!9e2Ha@)qVT<&_m{Ph3HE{Wu@O% zsNYp-;0l5Z`oYSRGY(pm5$P+&kp=(?CyL)mm)-xL`7&{?U+=mTxmvW)kJ%&6sQlKk|j8-_P-@u~( zMR7b|N<4_tgS*`w-mQ4sb# zURde<*defZcJUI5AaNmQq8&6ChIO=bj;*)J;2Wk(>@%eM^KL!kj5WNw)#-M&$}OvK zGfjL=dgjCy6aP<_KzI?VVCrDfp;csH&=Ef!=kr}llE5T4NMIuImtX`|h6P_bAa`oSLm(Yw-~b zF&|~lBoE1c{L|`-R{!fZ|J+zYq9g3zv1uX}_0J!LgwVPF=X+z)J^SZLaB&~9{yR5^ zBT4?xTuqtv7Yym)4`S>g>=`ou-VML`-}lOnhX3{7+XWDSDII*ut8PEafA9A05d7Oe z>n@=R^}n}!Z1DfGFZZ8kl8`X?Z`*`~H2?Q8aB)2%{?F#1q&y}DZ|6VPD))a|Afy*G z{C`>!H)`s?4^2r~_TR?)|3Bz|8t?z6gdHAwo0V<`9bGlp?1qRjpFsz%87=-1Q~ z7U_1I2Xim_@g%Uf4nT2%UG4-Xu~_f0Q=7u)BK0c>3)r8jqh)ZZejG#@{wz|ub1?ug zT8eum8|u+AP*tQS9gJN+vACTNf;!$I#J`dV?D!0E;z&@&z>K&kiDlL9Dzd`Wf(vJCF=X`MY{F98?6mSJLzmXvQ43j^Ce{e`oZ1y)|4| z(G~d|{8@|W)2Ejno9^1RW`Sifp-)9pxa>|N)ams5Z-^j2UMr>3s+ln#j!hQpAgr)J z0G?ORYqX}qGH8~qYXEd{)sS@=u%|Q8$SeTKxzwG zIQdmfSLE3vlu)vl zLf#77JQg6z$(C^mt9G97+?sUzkzg)WT}PlvbCxQ)wM4D)fIBcjT317Au8V!9EYl8@J; z08{MGJ|wt5{cb+H1;0PJ(_8U?_xb_$@z0`mO0&ncHk0k%r2l7k?}ZuLm+RN1Lu$|& zHdZ+o~>oOPz&C4uY@1~Ks!4hgH% z3#KXG?5t#V*Y;#Ly(*Sq#A(crO6NhX1Slq=Ldd?TAj~r^?=IzgD>W?LFW}>A_*O$U(&RApjvL9Usn7Y-kR@G)Wh>n6jH?v1vS= z@a^ngRM8f$X&`p062bGIV*2lh~hT zX%Ef~cE;Czuk4yNo0sd%@|)}tDWhe&y0^R#zHKf`tJ>gauS1GljC#L`PEIM#x$H)D-2&@YN~AKO9h!{kPZw-D-JVx{oKTVt4X2U{$zaTQaN*KfNi<7~ z(SJks-TC?O3i|1R>n{JR2wfNOeU(XdWxi6vPhos+-ss}N@i6({9KQ#qBqUCU*fIz) z8_&rYl+%RAsJLxZpi?sQzt-CNltsUd`dnpX(SzQh)*!ip7|YVO|15wKpa zFZhb?#%MO`h~H*?s%J`L*U@p^?(|#sB}5KQ3xV#4AMmmH@Yw<~+2^!?(8eaW=*b-KEsH|N8zHouOko;1a;?>uh{PvA@M z<}e()E_w$ot#mp-67D!3Ady>hr12Jxy*F_>o=n<6+Kd-0pNZP;ia`&M6g82AeZ=kt z1%3o&Yy=cg4RpLISK6gBEd1$gxo|lk2wF**J>wF4N%nVyjth4JrV$rbHZW$Ie{mNS zJOX|<4yz?geM-H2IeLDd?cx-EH`%1|BFcnPy##uv_&?tUwtb!|i(Y5y+32Db+K zv)oaYG>@-|`LXtiLK&xSZ4ICqjw_R-i8V%X^Nmi*jn$T~sAvb`fTc}-#+NWiRpHCoCN1d~q09SC^IxD_om zm?#XXj|81|)}WfBc9-}Cx+URV5w}Buno4k3+wKOB^L+7x{NFf8eLArXen{cWMeE)e z`J}6l<4c&`_UFe|>K_y8K)39yG-6-FCOl6DgNqKz|ESjJvCGD`-&TsGm@QaM zzJGw^e>MxNV>dL(d)I}rG%&S4-{59gd%({md2E*OodROUjI>>9$h*%5ZRo|Nl3A8G z>gTzKey<%?s73d_$o~8vEx>D2RmfYuTR_S+i09xYPAuC)60hb33{~@1GX9LB!9Ldw zab{TEfe1BN<70K=?Jn#UhEtBa9d{= zmI)L~6GT=|W+P2IGv$&tpp3_G2245djl%q;6 z$Vqg3L;CH80p*ldJlrb(mGV`5Ei9)r{k7-LBq_Zfg5;#8t_n{RSZ{z6_B$XD%fz1+ zn4S#YI$o7M87+R}0{TOW>!*9nDT|@Iq47sts_^eDigF7mG!h|C54bYwz4u<6=%B?R zQRPG9c6f8W{~$+GIq48_n8oMZEIZK~95SA`HM{)%hR0!jW1YxVUG!4eLCS2iDg_=J7;VJ1dMpzWEH9{r#BDp{c|g$pjt1;IEK?UU;dRlMRql>keOO!b zZn39(^XkClVvAS^UWN44P4Yb3)oW`X@^dP%#2RQ(#*O_I$Tk0{^mt*6yBXkHv43%w z7)}&92VB`6WM+>RiQ}D^lM8q~9Vk>EK?c}p4Q8v7Yh*MHADmB(k2jA{uH4>l^TfoM z4{i?~u3B09o1E>~=?>&Bm3wtk8gde%Kmj*#t%zxhS~rjN%td95>e}JPs{7`>&A+iGz;`L~y{z-5pakazZ)p|dtYxn03ytc|PphX5 z@~1E@;uiZW8PT{#M&Of=2T4_#Yx3Q%3uuIpw+7=Ax9q^TLwi!9`IuIGsFF^5(mYSi%@^)c{DW_P^B{9bK!5$PHbX<3BY-x>yeUOf;aq%&WZ^(xKUBZg5 zE!?apWYRrtYOD_4z+2bmI!X))6r(^EFR=_oehm9ISE57DpG>U!I=|;pphkYK`?f**KvJ#BGrL)W@6fAjhL)m)qjyAV=s_(Bgjfv)FD$urevr7RR6y_EI#zLxo^>Xt zyf2mjc;lG|#szHgq!&B$3OqNhbItL8_pg|>TEQ8kg+g&#a{@*p?9wXabE>YxU0P5^ zJEX%vAs%#EC4r9h1t=yzy`s{qXX9vg(@m|oH0{e#Ef&MR2e7BKxxc@_7IM4^?+Uy9 z8hhR7=TQoTv9XUh3I5ESB-}d+qPfK_wQG!%!H!YLMMFyIN>UV0en`iSlsf46V+^cz zFD_p>P4>LsB1Z@QVO_P_Lw?FaeCbb&d9)KMp1s@9m9?PTs%{*|Le;W zD#;Vmgl9pHTN{l~)=fX&ooPcIp2FNJXZt;1+{%G-0fU8Vt%Q%h?FZDo;tc{CM+xw` z!3|x67kA)91K`O{{0>*m&NgFWB4Kx~Ga+E8Yzevx@LK6~RmOcjGJ!^kDnr5aL+t{L zm<7(M|s`ABNn`x+%-%opTBQ>$c+f6)u0fD^sbX)9Z4C(e z4Cs~1rHT4dxD>@rPSqVO#;`!7ETcSQG%KGtZKfWGc9kiJuqH7w* zpXeePeSP04@O(9faOCBQ0AVWGY9KD&h8#>uRHlpffLXgm7MonSfRr=;W}9C0VNGUs ze(yMc*ePt<&2|2J`eO4+=i=ZGcH4{`xwLWby}nj)-|aIok?y--#JCQnWN6003`(fq z+Rxq{aj%*v0RC@C&D<F)A8ZC{Jm@NMv8(^j+{g-VUPB3f>S>s3L`W@cD~*ZXEMCGF~8=6@{WF$=hrSFZT= zuK}guvf&HeH74E06=rIV2Rh1T{W+Fp+?3;o(f-_|p<$lBc=ibOG8Kil-ltJzew4>& z0OiGvF_kAv+Ty#vUzLA8KG@>hoASG@ujLx2TKG(HB05?i$O(aA{V4_jUBo8s<}>G7 zrX|B(NCNCyE)LF<|2<=I;*&sh^;?TpymS+|cj|h-2`{oZEuPF#_PlwUe>J7A(~kP! z1~Z+6S&kaspzD-i<7(~ZKO`*(s)s!0@LICuhn#0ge0?cP3{iUrW!0)a<^=%qd?BA> z1reanAf7D!X&PFz!WU<3#7sVd7WRTWdcM^O213lH$0c)aE@3ZIXpgmZIPMSi&}l0Ho1CNJ(OgDWARkIcN1GI^!` z^V>kiQvKGGwe2(A(cB5Q#uR9M{B&5?!da6&yfHlKG%TEC`W*tHn`69VptBUYJ7oYu zKG~c?!?IFA)@j}bB*z111 zVyf2XaxY>5(V?$HG*bRnXN?BHBsg65IpB0%(zEOwqqwDBTOT~m&I-?TbA6}GfWc3_ zZi@OmpFQUdG~M|%j@jv7&ZnP#vH$vfr3G$Sh((4<^vw5#;q1lBJgi!na=?UsA zpNQ8|=qVwa5=p!a6#ZMuFxO{42I8tqGJt$Yn2fNp?7{ILuKax>pV>UqfAH*O?ZG1L zoz=pE5Pq!&;bK`B6q2jw(1-@0m9fA_1l80Ym1|8&tnrVCJivjykIDJE44oYiH2pmb zw!{8i#C{y}5$-aT#bkLS zm&KGOV1DG2n%h5DkqGvLm+4jyIP6{7)FDY~o}GSG+c*`OD7~oxhEsVnR-Kh9TAR9c z&l1S7rU3Z3Fof_qAA;%4OXeL=dZsMZb*BNCiZp@qG6(K3T1;a*7SVJ}@0G{pbG&yn zwlSYqk>R>D`kY?v*6ZY~l7EHTAVqm#>gRQd*FpsGG*DtH*|pZ5)Oz#oKSw5WVMvHY z$037GZ#}7L?s-)(Rx^~s5|?g8Nbze<3>o)#+@}@SEzaa-fQKum(m_-SMgRfc)#-QsJNs4m5WMW;V7a&rVms zuqeme8B+q=Qe?8e02@AlFr-<@ALLd>s@ifggTDOq-;k%v9;ZTli9wlYCt`b(QA?ZF zF-JB|FE7%k>1JlIK$u*_TC2`t3DlrC9?Lk2X7uv>lg=5|1?S3x@(3c^V5CGO2`L9U zuF0(?a1})V8K03~gF^|3F{KZE4yUzM>vZVhr!j;AgdW#G=sVpKr1YYkyocU~WX%Ln zQK)_QnJO>-vfJzs*{}(s;)CwwvZNPHq4*d~!3owdCmElsBVXcXs~L}sRcM?nAYIUc zVm8AU>@uHIjE06T$VDa{MXb8VRoUD$ZVm3q{&Vd=b)z<-+Q@~xi(ypewWm6RbD4KF z7wzn%IgjuJaum|*RDUfk*4h5m3T}e7p?K9stK|>D84RCVQ&ZmE#{we=MfG0~{IDZC zx^A4v>EIQqnw+oDTo)GLn#zyzeDaWu*uC4D&+2JEv5MZw9JY3&Wj5v};yja$SpX+? zdo9ee=TVTu4PS-xN3u9g`wCxQyZYHFs1cV>@P$RbKebqpMvdOPh3u!`n8p~UX**7T zO*kR`JG`4ss%|7vBACZ~^0c*dE!0JK!Jw_IGHo;4$hazAdzf4bNes5gE``!%nR7oJ6oB*{g$ z*V7&zhUm<<{EkE#v@iNCCT)+z!u}zfAP+#Udm7r`jqvSEt)WAPp6B1#tv=L*Wg4Xw zM{cY$WQ2??H%>sznu~SX6kn9xD0XA-{v>T?7R1@)kVY|Uezh6V&1AMqIXqfz@O&>v zV1O8d;ar<=`dgvlg)8ylMf@1Im_15WphHPS2h}RO^B~Do$*y_gcQub=7N|+Ni1R}n z)b>CmOpJQ`B9$q<_^Et(D|(&bTi1JG&O)4g>1dmpcB|=z52^Ya6>9NWe6vGR&gfl> zg`}x>JRq&k1WgifYe;Ewmqd>{^j&=yTZKa|?3!$NRCjJNeWIo!=(w9ReudaO?x3ma zkqM}`Gw0G3oF}Z;p?n%+s&^{V4mA1>VbV zad+?fIDf1%y-`K!Qu3l}EX?=9Nu&ljkCC>As6V_gUzVlbEL?x*g-vXTS+bo&9A%Ht*v9- zF6|^>=VWCqx5TqE3Dm@A=YIPG12YAD>CdfrcaBtGyR&z}5}|}|?!nvoI=tgEs(+o+ zQ)ktTJHJFv>IdJKw#jivly(?zv7L>Nk>VwrR@CqElw;=EnKSmx^+T=gs>p?XW|^p>f+z1< zbpt2;n%SHd83r`bP_4;<*Lm*z<)}Y^WH){yG8c_ho9%uT+2SqHLyTwupns1FTOEOo)X9YR;@;56!*IY|gz(~v_6SoFDhMSV^$3%G78(s_`9 zA?mf-2Ghla46NlK#5EFVQI7bv9wSYEB8MhbT#yYSQHA}8`qa+dIckoI|DO#HRnd>5 z)j}$hoF{rviAO8jL*Yh$aez90N3#r)OZ92YCS-LWQC9B7;NE%CD@8)2-^w4PCZw>5 zoeAl`1mloHaY%h9U!f4c*AlT%R>OEoSIW2M>t*m@MbPMV>R3a=W4aIOGcr_7 zI4|QW)Ce|fe)G=*Kj)N3))-`K+vWBK7&Cf5Z-u!`lC@fObN%6dYQ;p4Wx2y+6H}{y zl~{H({z2%pueBJ8mk;Ky`VFl_7*b^I zc+IKBbJc3;ufBN&<_oSE7+H`Z7yfwj2`p(&SYUHQXQ2woBsI!WvjeKT%10LucBcp~ zFsjV=7rg#5`cfQvo&Pcgkx%D>HhTqLLL)~_k(m?b0M^n`Eqmb(e6O|-I9g&}4jt9F zX8e;sO*Lm4F8sjJb!lmmviH26 za7063@uf?c2pf(nBuY!?ZfJu-HLWk$FuvGjF70iD-&#tThRyhlQtz17Z@;zU4kR4O ze{wyKmDy~^s$20Hbahce4f8JFZgbn%t?RGQ_ahA)5U?0VEnH*EM>1^<pwib zM7kBc4jeDW0mZw*c2AwH57qAkavyqS3>6GSIyYME7}yLU9Y5Ci^UB6V`)>2ki3@%8 z#Agcq>YHk^9<|vtA@hRt(m~HPC9q_zb7|@NHIU+B7e*s5n$-=mJ#TdHh=Wfh;#g_3J3l)Y7C61&ZzYgALH3qV zz^qUqwPkoJs=O|iQGb>zYSYe4e#xRZuGK@(1~zfb@js)O(yY{G&Slu47B2&@+U0^G zvB^DRpBr}^i^00x`w%C6AWa`$u_5LJCp`y79@EF5wq!x%-^LFIKaEyP0Q8~}J+?wL zEaNeXX*T zgck{mE-T;wkO#|zOUnE4y(%wxGeS(owE|`uOd&ROl*2*Vaoy48;`aGif_G^66}*^c z4%pi>stNBpQoSzLeupJIQaPl6+OJgYN{z|Q5I32x z)VSh|6<<2Tlw7;VZQj;C>`--jhmki!vXs_`7bpyQ=;DF7c{pJ`EjMUbUtBiwl}WR* z-1Z?zxK8r6(WvI@cvzf6NKa}QWzxG}JTvOQd; zE6!b9Cq0a)#oFeHbpg1)addT}Ye|%S^xC7|llQO7Mk9)j|D@SropYH9mt}lj>v+Ed zM>TwaBgvris_wopnt3!)xM_vbKjD<1%D!pFQO|?a(iYIj`g1(7L(9J>A3O;lnXJV+ zPt+}+-*Eqt86t*-{Po93_*#xu_illAc>*nvK_l&vGtEdGqF+4s`t^|eIwL_4K&@hk zOkn$Xo3I<7l}Q_V9fZwa^OfU79c*+iefY)Y7e9pl2B-$~yNqODDmV0b>^&#tc!H$= z6?H8~M{BqePR(g8!X@s-(@jPLw*1=6_JVxQyHQD0%>!12FaJlDp+=SG&9L9-CR=Vt05wemS;BkmD^T{){6?$QRfth33N zX!(~_i%+%OX*m^)HGN#boJ|g{tl$1=#C3dJLjp2I+^HoT=0`dW-fN8_gb%`r=rK5| zt#o>J!E}>bf!Q;0*2(=VjW+cve0+$IGb50N`?A%=4y%;Ug${*SB^>V>uVgjmwJe=W zQT*ub>2SRi%C9yg$?WX#z89ndCgLBNJh}sE789zMV1AVG^E?NqRXkGL6Ldw_b(nML$QFXl+;0SOCIx%zeQY`Oi!d2-)u=n~Vlr6-@ zM_9^jes}-d`O*f1LNNnxthjUN$_te9{Jt?{*f(8pvi798RAqkXpP?%VK}VHL9UKq^ zI?xuMUE#NHeO{^pd;)`SnoyDd8S0V9L^o5ZQ}1_*(Dei!(DV;%|8i?x zQC7k$WW&4Uj1?|MBvAX%(KJ50=WY~TI0Gq#N?yn53syW9jbe%l%H)1u%tfY<2xPrk zz3}>+;)$#KkAZ0lQIFy>wqXw68P5eL?CYaQUrsz=G?lbxfUMyV zO~YSEd$oOOB}pV)7(e2w;RC&p(5q|_6`MbW5$mdg`OYiqV7=X&jSvJ!`oER`s#WGX zCxAwQc*?DK^yP zC_ZUrzmaz;hho3!g^x*XE`}?m%$`J1N!cqrp&Khd;5dhOiq)tkFVJU%)RK^4kLSLJ zcpz@#FiCmU$bWu7#oz03keloN`~DAQgUdJVZ0{}wQLu;J&D3g=leMY(@J(Vo9c=uE z(G{}xMiIew)aSJ9pvI)3>BeHGnb=5LdYOJ34PZk@EkA6H{spgsR`A-8Qz_5xXn?!4 zzuNu7xp9URh=HG-=5u^nPcm*v z`9tZ~#HLjS^7f5Bex@SnG~u*1#Mew69gd>A^g%lZwgr@F>tATFv@DT6NqoHI2;xz) z7aiu?8lK-Mn9{QSVnRr@je#1O!llfM==sDHUtv4js@&G}ZVPOYCUa5M)>#s)rtFfa zVlhI%UQA|R)Saop>En0$ssuT(Qv&GK<^#pqdgryI*upDU*z+A#1-GZ0q&zMj!24E(Gzo-&ulAr}bCv}*rxIYEWaDT($mz&CD?)|#<1j7! zer90jotH`Ot%SIoE5Ca5U|5W5MHQHLz`lJT&)cHW@;? z2coAB>Mq^z=emqt*^-Bw25|skgRDTt!yhfdgI~EbHcRy!;}EHRj=N`E&NtQzw9na+ zb7Wikh%p@ccKno-!w!n%lVU|cdZoY&95aYAC-dJ5tZ6F4>FbR&dj7tP89;9#I{Z8x zAJOWfu=2%2ezMO__hK{fclZ@n_{`3x9;H@jaC!X`I^sZU7L-tyzSr$BlD^lFZ+Tv; z6egl3?MyXbFFe-HQhH&2wA>4fcKugGoIXCVttu|Oi-*^LHCWWNp#PaEn76guESUHP zlIj$sl#?t5`#8MSS_04@LJzO*CkhO@Ebq4uaO@#bzTv{uU~Ru8CYF~hd3w{{$rMi0 z@7~*W_{gbxvHO)xQtZzVX31$Q@E^5_ZtMLqzy92Uq*G zb$0%E0Ph6b^;e{$HMlAg3%nMSstXJc0(ow~C{LUVj`ERN2Jn2YsPVhyQ!*vgf=V|B z3%5FuFFeH0mWre^XVv4r#+m3NaM9LS_R1Cxc+&wYv)A_zHJ#s509fL<)z<@6E<@J> z^}KRKON*_`W4h&OcS}JuQlv9Z%)4{?KqPQLUVP~Mu~Qm@;IG%0?gu>MWd;@X8eN+=Zd<&wLzojd z{5Ba!p$A`Pa9n@<2{FY*nlXaSSp}@DOiy+4MpHYXb96-cj4|ioF1hA_7caiW{gqea;YwY9p?JQ97k-&aRhY-iJ!s*{| zk3eq4-+&hKA;Wq~kgbgCjiQqM%wxHv<6m)H*|MoJk%G&Gw&^0rN#aCJW2$R>LYaje_1+cQK0Dm{R;Sb zQGh`E-wXfrgXA~-Q*=1a|EGNbrv?o^KwpkQphPKwabb{3@UNKzZUpiOFZmqb-opn0 z_WsPJ+JqJn4V*f%^V95SXH^@lt3Ze6i1_vVE17U;j%;GrO>+KF+K$&3Ks%IM9$J>| z{Z5`N`a}-!YO(iUENV_xn<_T_FYRStwzP7< zYFcYp^)sz?CLJn=55ANhP9M2^rQUQFC}6R?(YJF4l`0s4cqHY<CG& zP2K){3`+?ys;=%C49(_lt<5$H2vWZfXNup0g{d8~E}yYCUn6OrGJ}L4vrV$k%B9x< zgCVLJzv=TIF3{KpD2?gp5Rjj7tq&Co)!+!%h<>~AkW)%&c*UM29Tjev_dNu+Gx?wc z$QC~ib%@e9_kNG2uyOoKwBU9(LaN-;;(eBPPGi{YO#$fiEJw>-955%xjeQ8e`)SU6 z)13;SlCI|Og5@?nt0qrH({xmhM^fHsE|OmT|xZbgt7nsZEmb zRU52vOQwARmYT%yQ!}jDMf1MK0yD8j3|LG1KyA((C)hi{Xp*^|1Dd|=JI*{siioxn zCJK=&8AJ5=4imFJkifmKz`ecmCfi)Dk5wdL`C*&q35kEF&&xYLph$2&5Y-4>g|akM znIATOaQ+~g9t7p>T?9r(&EVH|J}b=gRg0ak|M0$-lRwT`L^lH^8YuJc8ea_%tMmGv zx86^bXvs;FWegP`q;L-1FhjCw2YZ38qXr4VSq8Fk_gRc+ zqy^n83pW3{Hm~y=(=B%UW(3IvVT9{+rAZse%&QMA0p|3q)~|*fr6>uQuc7u>Jog?M zsPGVovV!I^ie{)XKlWFFQC`C?npQbY4%9^%SkWaF@KlX2nPCFSP)^_VoNGUaW69{x z0F%lNl!*+=Q{8PskTl=28nr~?mjPLehd9o?J~tNi@lN2I$AEKpFEG;qE<&Lkz`*yz zo`ke3^br2F9?z@8ehXX}!#5uG*R|Si7K`PL1|)ZR+~W4}ig#w917Z{M>jQoj{hIuX z?=KU_&^$@b#7*r?JU&$4BJB}mL)2Tm+391a&Wtc-RXI1xV#$-IPQV)vt@Wez*H+Sg zXtof5&^}kN$g%!w`$`+xkXvrawT8C5r@$$EB=?bLYu^JRpTBy3Qz1~=d_&>Uq04TPgjF$Bnv z;0^wn7QqPBEF+dO;>f!$QGRTO9#F*^dif@GHCxQGcy@@Ejf9Ro036Nbl5NFvb?3fB zvl;9f39e1~-Z4k$T*RMRL_>iN$oUj6d!WMPcdSN))wXrw3w^Zh-cKuyaW(H2Do@1z z#2?8QsMW-lot&P#IrL+TVogasLk^qt`>;|Aq4q6hQY~<%xNS)T$w!b+kq7$ER`||E zdXwLW-fw~C8cr(2!a%Ch>k1Sx5Wquze9uFMbJD(<=e9(Ko{>|5E%$e_z1Au1uY@nV#QFgD|D*aHIgK-2`kPKajDF`E5i~$uoZMWvuL+9Z zpDHQ$;?M2A(z5U>j3>Tj@0;Ih3Qe1(n5D{4{IjzGznx%eHYj~jd$Dc`a;Vatxboi6 zJ;yiLfGTI;&eF=B?9BprXxW)%I9HjG+448h2sV`NGQ+m`*3P`%D)XUSucm_mr}7hL zJ#VEzjTUO@JTBETGws{t#3vem)@`moegd^MhDVsD$^TF+Cg zpt;6ArFbio;FC?`*cCEb>7WSl?TpvwFhMW?`fWsdVuHl!K^KUcAHL4ua~b5gM+#$Z zkNnzQz1Nuokz@fhb2s8~C+Ib%>j4wCPi({02lY>jBI%5|N-Srxt~|<}4#+`>t&F(+ zD=u@Ww^~ik;1>z;r=sGLxc@4#NNo@trmFNBuY#FCH6(E4EPPHVLSDs0JYM>$il8(e z6c*Ut)XcVHuna_cQF5a=baE79er!>^Ia=;2nTma|&37+qws1;-?xRSb+HvM9)3_AI zMt>8<&Md>S@|nw|a{o!)8T^C_ROcQeqWxDf*vdLsCWH~xDjLJ(1xg8M;C-^VuHp6%74v9b#a$-$JhHCn{Lc%{q zwOS1O#BI`cp1#O;F0U#knno$(f)cRl*SVqb>^0WYmD1);v2>e)1Fsgkzme$Pt0bo$ z$yYfj-iuGE`qO`hF%PtrWnN4s)q?b;X!JMay)+%|6Y=@!beWyfz#Nq{>*KlFmgCbu zoPW~zBh(oU6-Y|cN{NUYL2;t*jT3S^R!l2fqi)qaKYg_um0S-_=*hni32>DiSjoO7 zuBv)b$`1V^m;1zOW=EYzOOWq}jh|KAK9se83eTKnB=@MkTk?2~E#YQqkvg1Q*BlKf z#-&whm6_t8_)+c-A|sLAH#AnJE43{Gb1zK|{*{1MTf>?mZTJJ}ou=YsFgGB7^i+uD zpW(bUWALGCK)Smd>6UIOX(ru`(kR_2-Q6A1-QC@t>*9XyXM5lE8E^0!H;}{JScfJr(IX)YvoiThFL)m6V)3Yv}r`~Ct9r(QE+EyJe8|-QF zf7r(g)(^9rJk|5qsXBSFYuxGmvKbIyF_`yv5`FvL6%pkHo+;} zHpdrKFY4(W0n_Y&M~|W9YGeIajHqWmP&=Bc_2Ed*4-tq^_Pw)SUr1vyt*nPkFDHXB zV}bLp$Gm#KShFFzjwm_C78)yH9A8hZ?xcxsY+=0Pq~fm2O*@jeS0|Q;!u+R5P$E)x zWf!%VQc^D@h;cf5eykP)2bk*q3;(+P`O9n0Jkd(N`TJh@2e@n%jb|bKsV@=g(s=k4 z7FEx7ecS1jKVDVAvJZd_juNDziCbRfI`5xsj)~o`c9LzIKrb^|iQp27|2f{T6U&G0 z0MRpTf-rzcw&eKy}Hu6kn3ewebKY4&EBnw z4f#_gFtv`<3QwKWEoH(WvZ4q$bJAJapm<74?r65!hju@_Pusy?&-MZ!Hi0-UH^ zk{nHCnSjBD#C710V0y}jf7tE$#KT|;SsfrLujg{gN8j^Q3nmn}azr1Tx5 z|Fl1yG*xvE_b-k!{_U;S;M-t-nP;s?w88@*ty~MPR4(o#{f9OgXuqlfs)^b0y!cds z17p8|ZThbuvv&gJ^|a-_ZcAc+$R|y@Ccy#{8C>Q+Ea_vND6I8#eOtBLtmGj;r*fhZ z3wqF}KSw20ROijU-rLJhc0O#j{8*Y0??eDk0_ZE5oS@6vfLcdq!3R&Z3%GANn@t`) zoTn$q=hF(^LGYBw+HVx6DL%Q#4qk$^M=9|#gH?Zg59JWj2zYJ3Ei6kCjFu4`m`5gY zYXph|_r~me$(-G6*u&(Xf@bz{3$P9Rh9h20DG|Gs=Ca7o5yr+sK=&dv%J98h$O0Bc zmiCUA`)_&)bGF!zf-ky_l^!DizW?vM1?6kc((dm0e1+*qZE$m_?C22XPH$1ol9Gj5 zK@PP>$)6GheSV8!80HL@SWtpqCwWN3Y_gK?D!KCqJS z(w`0@z&~eMgu;AJJrkn<@jCI#qzG~0K9Yf+;#An*HSiln(TF0gt)&g=*^2ZYt@QMH zJSXvq;w3wm8qEOomyr^2|2KD)Ntibi$LH0p>j@STjzgSRo-+n~P@vR!Tq_UT(bXrb zB}W{kpw8xkZx)>dna^4Q+`M7YC^Y!b4Yup?fQ^v!DXoDO^V&2=Pif=+cxLJ{1TMIF zoG*#*OO;$kof@%#dk(nW4ziVp$HPTc>1wMgLD!%!E@9*QMXbppz=xu5954Bn!g$(y zzx%v4Buv8{snKbu-RJZ+ef9;6(wADf)@SLzZ1#l40>gk-`y`OG{dGMmg@wxGmMIaR zXaan`c)5B-vB-8I5dHnIt574X5oKv|JnF|zle_w5M znY{APNG(_mb!6@zYKI~d^k2S2IxV*8eby6oe;xxY+;ZCgnigEGe`U)L+nc;;{60&? z<9S)_k`n1Lq3&&__v5g54GxPo@2~obi7p8eZ!jF0?Xf~!g@R@Gzd1eF+7)75dNs(q*kX6p_xx6KR%I^cJCS_iPoG)o9N+UD+V}yNLV%+=wBb zPDG*d_oo-EtEbuZsfuCLs8jG-ju^=pRArPP_}ePEq$V{;96%qbe5eegV9cF5M=LYtp0G|?YiF*Wn~7F2`n z7N`Q=xvOqsZiD;p16AE7h_E zS;W0oIYvL-tiqG`EO#I}-+7*F3LwltX#W}uz7|j-dJ6r)Wy78{=zfK$b@(!>O$&YX zJr+jqxFM>#^fEQ0z_VfV|7^xbxcrGUj}MAsqV7Vn+Jb zzHIGhtTi>_Uh@5VBBmFf;yWBo{~PI8quI5ng!e^B=$UI~YN^@Pv&D+n5fCo;9I z?5Q2|elATwvUg7?7Xc;uoqri3?N*O}W{O|xcpwoYd)a*TZe}<8kW?eq03VI71o{?m z%Zb~Xe8zB%8=y%GRka&?oBU7Di zeL#mOq@TJ_KoL0q01>aJTRP-1{PjgZXX>#2{b!Nm?h`|sU=p55=u4%^I2qn#LptXh z*gY&V9s8&Mh^$So3rmjBTi7 zUjc?deLWP*bfGcJM6h=xpp><}40^UXt}(xNKEG*&92Z#&!@?e3T}-OtJC*9?=e=db z+IrO695+JyuNv|BTrG7ECIHMlljhK zYz92>?_b#tdhu(5BAuS5qu&80NsMRC{M(&J58M@Qm$iK{-yfh9&LYpG zhE$60o2o=W57cqED2>lyuXVX~gXUH1Ua7W#qk!LRmp1EwalM(A4lQoL`XBHbJdvo$ z4NaVCPa|sg%5|NJ=Odd}Hi}L+;u7~5AJrAoo3cEWjVD+oBMR|1L@sb3eZQI~F_@a8 zQa87ROaNm6HnK+8Nd=BGv(6yik8^{KIsNJ<@T$q)o2;Zv$6|&QeX8bC$wa+?DX{8> zDt?xww?0HK$|~c&m~g;T?QRv8Qg@*e@KjUfaq2bi98AH9)sOVcQT6C2{nPLWv3r-0 z4*MM#wR|Erl>EFD3otj+bz;q~v<3D=tNB=QkP4Z{<+hh`Zx;d)@#=nyV}M-nE=$%y zvA0E;4)WbyL08gc#MLbIdxW9YT!ev45xM(L|0y4<#}q$6jj`I4Y&?_0Sw4NG&@2AV zo3wG*_;3QWjiuwOCDcd_pZ=5nsa;_hsOC;eoOCaP3fL2EKi?;EiZ zcoQO0fxtqI%ttLZYepjE5R+PrK;he{VOd5&rggH$E?X+ZLZEexSTlY^UVyZ^1Mn3( ze1cpPnDxJ9o^>n;3(L@-yDhSe9;g04c+Y9?=Ph?X5BFuMB@SX6K*a{VcB7gc*RzI*H@%hbd?W zt3Z2?#(--8Ao&Z@LFET?DupzerPJVEz?tTN%37dU#wOyBktA9Ec}7Zcj8h zSTo)Yl~z!ti#?AwkNZCGTFvEBM#KWuc-b@;5_BnOHrVcSyL8#&hWPnCvp<4LO7Vpf zaFdEDw;>oRf0c5g@<;y(GqQpUcot6@gaCbDd_dGLdtbjU+uB$) z@*FJ5l7O5YJDcnMS%~clX%N*ny{&NHG6`taxx~cS#^`rupswn%@p(2&Y;!4;Chhse zU;wkJeD@?@Q~T>)h?I~9BYD`y$2&1BD;N(wOC|ksx zvEp}5Go;0Hq9NHh*E`Re=T2ojK4*VooGw4SYmFnAvj>8mzLfa(yN4SwLX4V(QX<`B zImnU9%kesvAzUjTX5KVvzF41^Yj>ABtJA|5jhAMLW28}4<%D!sgJW*%A1gryp4z`wM7!xFoGbLtt76yiVY|3JyBJ-d9qSnSg7g&0SCy$)9tH!=8a)23^u`$c;g+>Vh zMa8n+09BquDhZVa_byfaf&FGgYLC>S&k-gFF8%@XO(dTxycBnsb~i(jPbx zq0_kUq)r8yN$)2mGAjiM560*NO131=!=1zodFqK>Pw!D}l1HU25thG?&vk%_$+L1Y zO~|l}A?0W1=P#yR`6ctKd+3UpRyHR19mmB)rmg$sYrQ|nTz6yJB6ooiVsRcc0gvzS z1hlmjvMEY3IR8rgziS+J;sb2`_}f36Bs{n6Jt6~3f-WyzJ5S-s!zF)Jo?yeGmf9&` zm_8~qoHM$lxli>86n}1?wz4?Yf=u0PMdI!E_Hz=nwQx%R+E40|>HIr)KU~2Y>1Us%jxy!!Bw~V7ZhpeQ(yzsg!|3Es_eQ{J zki`;1?V}6&tSbx)?%(ghF=QbUg#ei=B1wk4PyrPQKJvBY9c|eC6(C7nVeeL|xI(~c z+Uiev4_sOT@u$IH}YvH{>xp zd@#tv%U&?97YCSYB}eK(c~o*`)^#P*ON+j8nFZo1c1d1fT0#z2Ga1|_+w7mUaEm~G zAKT)X%6Ep;Y^VCO2+pzI!~B3TTPjeHi@e9FJ}6i{CcAbb?XG41^7zk z={%mwf#Rsdf|ndUJy=NXq`sfajDKMpqAo!Xb6N151J`^W%M4^cC;q~*?c|W3_kS%z zIl!}YXDM6NIA>02s|c!9q}XFk->(uUjLeh3IR5Blf6B<2};MI5^|W21UlB zEWki|8g(Uu5^prufbVjMC$Qw>cISb{L@pPc9}@D%&@FJ*ad|A(P@ z*H17okusvfpPd2Ok!BLF#!A8YPB-C*HWX**+r0<8a6sRm_K4LdP+8GXm^jb|<|hB@ z`*0Q;$*+TTfbusqJW&ozX3}EwD(VtBK@FsofJ4ikG~u^7_PwRG61yD(l04fZucqgX zSWeN1A&A_}ck^@l&Jx}XPQhT|ulBC4u8GDB3RPR$)ZeHTXfWv*82eV+H0`O+fL{uJ zF<_gXYyJZ3|Nb?3+SQ(RTzh5+Qr>5Hem8ZwUrpv2yhC(@qV*8LjZ$~n2}iOM6=f_2=j#30V>PpwB!lHO(? z2`kKR0$^Ye@(CxEYtbbE6G^YH+R-HVti#Qbh&z8k`R;(&_` zVbS+z)XeIOUrPEXFrU57dO}fK8@+BGcoc=L)MTQAl6Qfi*~rMCt;VI@;fv z>z*n6o;0SN>l}1Y#RS(7NZyX~Z@GjjSYKS^01rQ&)kg13Xd-&Qs0{A$U5iz??p~64 zxl~%7{cnMZPe3P^M;@A!IzXFqKv8dlS6qOU5Gxgvo22^yeG6;mnupHHtE|Nr)Kcrg ze{j;_yuuO=2RA(xX>L}!9g;qnOTg!l+02<;0|GPI>dG~D6N4qkb3_=$s->V#^tENrY&>(5=?^w~tvG1V2=g1~*$ zi_y}bt?ASVf-h3&tUOS}`Vr+xS2_`4*SP06fAp1?C7x}w_=WmtFLZ3L_y7@&3mqLiS~f@z zGEcQLge0eVgCVL(`8I69&_lGRe-s4`+a|Q;Sd>B{GYEtzE+W3SLHXO()^?@^gxFIu ziLxU&&?Trgv zge9#Ltk!*Bg!2e2gFvXh&`OS$WiqX*GaQ9P$b1ET<&v+?cF=BC%`Q}}^QVbl*YaQ` zsS6agBH}k|LsSf~O?2Bs5|&uK#uN}5c-nZ{K6-~(fPvpXC@OZKGrjeT;y0-YYiJjm z;S{2SFnk!T2otvT0yqJyOa0~DcZ!gZjlz|yvUw36!)`EeC{lO#=RId@*YQ$BF+b3_ zL3>6f&bV5;9Qs;5)Swo;ol*^A2iEbcVSwYwjr*9;-vX8kEIfR!X zeDot=RJ2JESqz(AZm}q57%g>0sua06Yisn>5R`YM-}6k4pTINF_lek?(bGGeBBT4p ztOvd!KfH`(nC^9}>k4g$-oV|cV1qG1>^OC?WO)^JtF<{=mP>)16VoG+eubn^83;l- zg~M=kyDe10U9I{SH;+b@?M7U8P}6f3%t~D`Ja2o2NY5-S&ZohYrDWJ+xO~>5yXQs> z$6yieVrtg6WLtHim_$m<@%j>g3+DhA9CY!+R%fri1Orn{rNHb2N&;jtRoZ8D(A6{W z(pJMNhCj;@`~vFfA@<;2UudH3IlAdx1PV$MuFg(Bv5mdd8T8Q0gTfbZDts^jxRmqw;`kNl zw(Qj{?umJ6wVNRp)KpsiaT9F4#W}?>{UZR`I?jL(01q-~g~t~56F%dJYq(|Tad83Xt_C%?Z{|`W2g$_xstDv#x?ghnr{hRjr8I?Ma{210Ks+|1Ye-{U2zbOKoPt^mN#k zC9(+SM;B)O7dds7W1Si6OxBgL+2YM(!xRd&^6AxSpJ_yU!rX}uN|9p2VC%uiZiVN~ zPln`O-{-bR>%rEKpMIXTezPylb;oQ6vD@5tAUxOpQ7i?=OVr1eLxr^ZNslm1{2lQC%giy zvhdR3mTRmg9@Rm>&zzrV$B+lBgLFlF{2U;gRB*KNtq{`unMk5LHvSm<;&5k z!bGR|HR2PwlBY81I8Ibm#ylRij+}+FVRfW**eW@K;pX+{Qo6Ek7leLBMU4q+f ze<=UTs?9L6X_SwjkB!EjFIQLD^v1@IWTUO{@zd^erHu16U;CtR6k)=M9c&h1ci4GO z(prRl_#cm*QQ4>|Vr4O&N5S`W88OG59OzFZzpGh1D>w8n(pT9UJp`o|;(^ud3^o6D z++VY?WpM%(wG@=jP#y5t3G5;2ZzH(%Oy9e^u{6iaI_s+J5y!Z9Pm=8w()hR!PSW%Y zln$1FQEZ**bS{bw9z(j)asOt|*CYADdNwv{vDHRm%z)*@ZUiasesr=XUp*4|A$OwD zTtC~DnZ?h?gu_^==qiMD<{Ey3p%xTD>telPi_V8^0ayED{Y(W8LFFfD*;aI20u?o& z96>RwcHG~OUouYLdInGiV2PTKyX#4jNHT(4PFX1oN3L#fbCVJhbAqLov7t84C7>ik z(1Oqmu?L{$A1V^@98tOg7smn_YpW1{VL*wXL23AdEx8VgD+Gdxpc7l6eepeJ3!Let(dU`i^A-3sI8l4Q z0zAi#ZJTko5{IRke>MWI!1IINvH$ZFIN&q>`+I}+|Lu>2KmvC3;kY21?bv z@YKoyjf@8F7fH!#4Ib_?09$#frTOXGJYw0kew#yRLiw8L>M{qXAgDL@%lN@lnP>lc z7?S1h>DA|G%gr$4nKp0Lu)Ra{ANTjJ4rg0+VB;9trOU0U!>xMPy#3}z;N_P)i-_BH z5^IFX)PSyY|GJU9n?OY}5PK{Ay|FCREfVcF&+cj$`*L`7yF7ES9Oj>a=xx$feXG0Y z-CUao05qZh1cGL379F0?#Li$V)?2dy=@8|+k;8?(0KfuV1rVz|9PV}(Bq8!Suuf=- z5)Ej$x5DQ%Lg6S?k7do1xAY=bW7KbwW@WyOh%PaTB+Ctf_CVxCNiPbRI zpjQ|?Pb|vLM|6yeN1*v%?F@c@c~vx-+v14h(UYM~;_@t9*VfE2XYj_J4JZ@32x4(^M~=ry^7^VXKWKmM#V zn#p@~I$GHkcINOf#W3d`u~G+S$>1YBO&U zzUB6M(QUrJV3{q{O+nZn7V(wSMx(v=^8lpQzF>`{M!utznAd#RRi|`VLrO#w$LF|9 zW_zM$DD^wh6fL7hYFQr%c_Em7O$|6wXR>>tYMof52N4BB!G%E%jAZ?FF1`Z6;>@f+ z%FaKopRaZ7zn2JV0r@1YMl6O>y>bI8bN$Njflc3>_wg|oN;crHdlj)7(d9|gbrU0U-kf&6Q8Lk^}i23{s? zXjT-m{&D?e_O|)L+FSaa%6od&K5Oe*0d;(HBWX=QnYC@+g}k=u4U2;gxRO|5MT+k>2{}@1zHX^o9V1%#QAC zO?Qy#-~KwfWe?Y`RGVM(RLu_if_)L_>Fq@ql=1Yx6FYit?Z;lF#wNlO*II{BVx`>M zMDms^ZJ65J9>b;zq!re=iWMA;`*zMp?s57AJ^{-vFq3HQ^WJ{vEsK_&N&&*|aB}jy ze(M%fv-3STrR;CNqi=LlTC9IJLFDZ2OD|YXuX}o71bkFjgX!^ecB@u!VV)@ISqI7j zHo?N_SwB4qg|-#(`6~I|ZQ46fNnR|0VqYsa*rTKAre~dwc>DW>f3$$d6=9p_y+3Qd zq6C0Z7aUCTAC5c@vzun%V2%8VuRr4*Su?~M#>}TI;k*u*8W>0n!x`zzFa{8-zZJ6B zIRGAic?}Tf(Z}Uq{dLtk%-q8+`fvyn(#f^Rc|mUoKuI(LXqk)Ebv&m0rivojaJOA` z7Z&RuV?R^ARDCv!R&PSWVv0+p8q=2vjq>p@Q$%$=TC9R5;&A|u z=0+R0dZ1DgkL~GQTmo=Ls=?KL5zH#9uRLNvHu&^aO;Pb^$(16WK{=-WR6x|u+p9>a z{Y0%o(mnSym*}O=nqy~H3AnEAynA1^u1yucnw;30}<(n}g<1_o4}j!r01 zWF;4D`>OjamRhmA-f8|&PA~OYmlUT&&167ypF-8mget!C0l0 zyUNWUH98;AI|MC9l5la>y`JgFB%i2;K}wD1ot3z4^4D6eD+4zh4I&aR-@%Aka#dJ+ z^INcIGOIaWI=^SKz}hCuL7fkQ^J_MI=x5ghjyT>1OL0pTDh_MhvDdkKB2$^fuygd& zesv&2N-q<%-(hFCdJ10#%V@D&9g5>nF<9@?IyVC3P zI>E_FgL+2}lTdUA!i`M?c)O1eVaQs8j;u@qcXp3e(Yp0AC}|)*2ikE8P(ekWWXj`I zbG2Pf3SM7r_0E)dXTvF1nG(M&^2ncxqtk&AtjOi*X&&`F07k!O8mwdmQ6iFF_kg*_aEr}pHNpsBvrB2RP|vDjHoGaTA1G#hve{vmNoS>5X0OS5cpBA7 zX>-F$=l1^9rW^Y^%Vt{JY5PtugCydqj_p43)Dva8x=zPZ{C#YtuWYBf}e0`gs}IvOzl6gWRw?~5MkV8f*|gQ`TY@g z{>hkz;TT>HSiQQSbV(KxmctrB?BDv_hM3W7TDrXfV>V$_<62|y3Mt&+47yd9s%Pas zMD9kjuk(|bvup^n6 zibaKIigeWGF0hy=0f031w;Kzuv4Qc7=@IA>H?qIj`!+480A;qNw0>45 zD75*W3)P3-;le2>rocfDHnXLaK0r^sl}v_KZ>{^MBbD8XW_LV&Rx=`5#66AM+h3c# zScLd2*79y^_C_Lz$wH~(j(#OFHEIPYfL&?yQ9I9BMLMo7VpYj zY#||{uaICT1NM+x!Ai$e97Vzz)+>yp7hda?yzU;&)1?wtni2lYHa^5bw1RFAGmQfP zdN%c^fa#ziL&!d(V606_>7TKCk$rnwtHmNvn~!{JBiMfue{8?I%-*{#acHllEaCFj zo^Y{|2ENGOwZJbbu`fK0XQYh6XpfiA9<9PNtC?)1)B$sUO+HIT3?kC$k^Djzz;Ezh zs4}h;z5w)=Qkv{JILcLj$zTrk@Eldbm0pj|$8kEdj#0vin5}GNHf9FRUmrbv2}*6{ zsjl289YSkD*_=eOGn*)umd7rVl221Kz#bL5wNl#v8Kb8#X_(P!6vKrpqZwT2?6|On ztHZr&+`@qy0-7oGhf`5#rZubS%j0c7qv-cZ0F&dqJVp!WNsjVNV$BXv!7y_Mr+7xYovuR+FG5eg>^Ry>{)wffHt(XQ5^b9rQD5RQ>lHLM5}E z9)qlPnYKNeFT)Bi#RZKPw!f#OBd+HTwz9jaozQ>!2E*Jvo717@S0LS_@YgX_KoY-cwlqB}jc_uA8BMNh%RL_5*KXShzW{jv%sCNTRoR!W3QH|VY+ z`!_W_v=mX{>bL^>k(g?ejUQ~YxS;X>6poO{_aF*QH3KF+%9Tcm&?>6l-t>zQgt_Mav~ zQhQ+~LcW79`t0EJ?7t~xghvU!ZFf`^{s3yc*o32-CTOfN9Ys3tlWOJP$?j%ETKo#o z#nCH$hV@ZuQuuQ;vi6k=CwOOKuU5btU1#uyP`gFu>mwJy8D^5(X6WJCEFNl#YO5Pc%lig(_AH3%*7Ru27X`m(5-0w9nLJo#vh$%@+$pUw@yT z%VnD|ARRN=M9GHR1>Z#6{pp*#-W5`qIF)5EqTRUEXlHmY*Y0!v&>r^{(a8mE?dzCz z0v?Fy@Cm2&s{NebPXhjy9BDyAcb=A7bkI-lhg!?;%~iO~%cHtw$;IXXb)}ibBi%(d zmHBqxN>}S(6$v+MHWM41Zxd&+2I%^UjVP@qQtxxK4{PPN6B&9y0nQmFdkPB(F;+FR zkFUo>GbS6zoWw4^9h5_ln~l(^t&6tr&FT4~{?0lmJm=wS=(Zi{2BmR>VxbMQ`Ulp1 z-u(B)nt33PFmiSp)Q79huT$jtAa9WQO3B8#Y}XO*TL1BK9z;o$%innJ%mhj0YALB1 z)_{xd#JSTQ9Mn)KC`7JXY-8PU^w9#tBMQUCr3d=A|f zf2IecrW)0k95-nFevZmF(v9DF_EZE)Jebd1x^V6EKozPll>(-gj4EKH5GihT{4N-< ze*9#na^uq+hd&?9lm0YuB4GuQhGFzIX-No zRN<-#9UrEvP&O5YP}EdUJUN)Ur9QCl^;Zp!yF*^f$D;Q_Wr`jbrLzZx_llO5M;G>0 zUR{CJ;FRq#JFa{Qig2Vvr&FEOs~q1sR%=~m@) z`g97m)`XZ)VNM3oEd%sc^^(cd9JfaDopzsWw%M3ZqXkbGOt7LL(i_x_ieF~mGjZ0Y zDkCq9=VncmggaiW27Hzpok{KqZb*(HNbCZMIek#pxt3~k%|(+6Z`*+t?!rsWIjk$y z{5IRBhjBDzuIBgbc6)^Ux?rVADuSLpRY}v~WZShl-qlA-ePqoMwb*=2TBg~NehS-9 zIa7on(ol~`x8SwVdJTrC*vKfJaD*jmxxAcD3K!PQ8z@ymrwJ`#`tHA~32ro^oari| zB+d(YJB4jNe>C?VE$ltaUmmm&@?)%wpr&*jyo*TR9hdEV#kVk>ElVEARD>e&Cz78~ zl6#9VNEweR5yFP(mbA~yM^Z~o0?#yCZ+^_Tn}mQ_*K=LusngJ@J#M_a*}BDv=evMw zQ(+Qh&UHDELOB>QlH6DfPrL!N`?h(8e6;+o361>0!4ZtUy8YP#q9zD8V``vBEe$w; z?0EK7f!#+-H?D52gdw=s4`*9^tY?$SshsT!aQ@$f{(ZFAmlus{c1#jl#v&0HZ9wb^ z{RtDj{^4kp;Api8+Ecsp83Pd(?kg~hP!QE7Rhc-ZL5b)vNQM3H(a{{fI_qbH-W?%# zR6Cct_3JQaxZg}gj=4G0P+lJF7wKI_*%Z2#5N)srCut;t5NbG z?#64XO88F^yL&wtE^O0}Ba|FFq6VjerS%4Fuiohv`)KMf*_Wow zCwJHtT57Ygm=s=sR4~dy{-I7G)N#-5ey1|z*J1U|Fnef}0b>98@nRG5mV_nV(QFjW z0vuYT;(S6{E!zqGsaTDS*L4$rU$!Rz%iYr{fV?{@p>(M?JjJQ#_hg3H7fax<6{ zJtOkPlLS2X^7fF6FO3Z!9(d@ZHr{63rwgP-1bj&pS#sP}GRqlwnuNMDDoJUSskz=G zi9CXyNcTVWmOELhDglkt13(s=Ff154Qag=FtLYa=NDCwpumKkaY}~CC?~9*6F>~P8 z0*<>)PRl*9BZMa%^*w>>nezS|^5Ew%K2JOW&$H1#-tl6S^c02ADA;_O8=`WZvKW=z zbXxQzOkre|an!#PQ3wU4uM=(Hszb>|rF{ul;#YFT|B`gP@5Dj?LjH2FD4APN{oQ=Z zFO9Vm$4p~Pf1xDY#7_n7BRrgch`?iznlDO<{Zt88a#M|COo$%yvE`!;(sT8_h!Ah| zj4H)LLM3P86;Ym-N@EDQWQC0*>wcUnknPGYXhaPf>#CtB&6p1>_YDzOQgfdpo!iKV zo!)nMXe1Q92&AJv8nW&RL-tA+)YFd1T!#gQj0J(%aRd!>(*c*E_{;uT;8#OIrq)QM zwMr5IYT+0=mJtv|5?i=Ul(_!AD2rQ|SWSwF>kXUAL5t79K0Ri-m4ybZk5t6$+n2jMi>oMfbzO#iDVw)Z55qWGF8%KDK%f6Yw-w zvjdGwG9T{hSwM&)ziJxivW~d)Rr<;}<;y(cmuBw#0c4rY)%sEDr@u+4n zcUDV6b*x)EAW|?<&7os?o{eEGMM@pg8*(&uYCTdd7^<1V0G6hmyl=HV*R0{hk`9Fi_hvQ_ya17VLk^rDG zTY|lci!Le?JrM5~R&fuU2|?kM;D8HLW3RDVexWXgP6xuf`%npZ>>W6D2sHXwF6m&7 z)UGO%zl=G+WZ~tyW{ivVte*f)R7P{>uh}wDh#<(FcF)7tkT9IFQDT+g>-{{tAzx`< zk5>tT`qS-gQr>p=V_A2Qm&``g&K0#49LmPUFHl+%2}Hu9dt5^y7QJSr8=DZp8Y4n1 z0pciN4(T}M(Mp4H`pL-T;%l@0ZU~up7n||h)vc~ZVf2-0n7sx0#45=W*Of$(5HWt(7)`>|OhhJb;Rl*-b^k|V zzg$+ab)pY;WKO?z1>Q8z5j}@V(V+78xW8j%1`gTc2itMo4Z~TY6A#Tw3B_G31t&T@ zZ=EgO4i6@M#9OUdiups2@Og$J{2v*&;6j4bo$ z6Lnl*sg7GS&|#Q7pFS$!&zMgf)_;J@h9@GmKB*5E%ri(nm*UqJ4}&Z;l!SF<(t7dn z;5XkOxH@|dPkD$HzO|c@HGOLv9G605T(@qTaTz_&zzJh)OjlsqDAlNi+Y8rUwUveiR_b_#x?lc6^)VdMO zAGwsWA?*B2aAzO+L?>wKY+-+`;t`rvh9B0&0)mbaK45i#yIeH4L-2 ze|CNu+^t{ih6-QGe@x?nB&EC&jb<+3>a4{6r!r_12^aQG%4``tANV+$7bX0Fj~(E$ zMtK4D6Z0QK%s?Q~U{|noovpGZEH>@~1J$@oN8hW_log7Q6SNz3XUkW2-=wZv{#*|z zj}kJf<%ixgK~V&8R5%0|Ca)?8RQ?^s+7b*lG!U6tXE=;$9(Ik}uaHko_KXGw9l~~R zNb+$P#;zTO>#pZQ7RzW*!C5OLSA(OvwL{-loa3`@(COT$R%)~_lHtSmoyC=&3fM;0 zgf6D8?OhvA`MM-D%nqWJ`B$_Cs{(=ni(9GC*Ua(b>$!ZNd|``o#B+@kiBia>y^HE> zariCVoamB+jDINRE)%h%j?gW4A(e-~e3oi{b!K-+l~DlKcog2us*zTAh)65zJxF6% zCG@jSWb;5{oR|=zV{*UoLoY&79kXcAM8(OT7P6Ihha!8v1bN07xhYDJi!r;S+J7 zyx0SlfwH>90Y686z7DZ=7m@ZGO(A%q3t!=(1Bu@jy}ztA?k&7C*ZL}LH^c(cAse8Y zPT;XI^XMVT>kl)3A-IOP)_9O1bw6z8TaFPty9O%VXtLzs@lXL60MMm+qx+df4d)Aa z@u|se7seBwOpEU-6M7*0aJ@H@@1CYwhr%X`mCS-v7tPML4zcgu0FrDJsF-YXWxOSJ z$$kOMYx^>M3SH9^6F&dYwQ zzaqy)1Mkm;CSs~;3boqosHJn<`uC%_ozFg%Ew_dOZb2dp1hdS7sI<>0>3PHRcB;8{ zboiOUSmEES^Ll@6O(zCl<7Ng!Z#t)MRC}MJ1I|J4vyXdL+iVhUGg20HoBtOi5cR-! zOu;`igOwrRD{aoC_c@cxjz%v9(|_o_2i6xTKO*RLI7eUY4T=MrNzCn#I29{du+f<+ z87Ij!#pamX*MR>AFx`YhZPj19bGT{i1#mGx6uYbC#`Rhy5jCu{&Q4QR*cDN?uip`v z^xJa)XD$_>B}=1swDbORK+yf}6Qu%f^wvNm(bn_j{e3|R*V#leq6Q7nD2XGpp-t@P z-&`LD;5lkw@?MSE?vB|zNlGzeGQjls@LvtiOo6PdBPiU$%e;?2>hMHcsh_W}XF$>N zEc3Wf_4vt)sq*w2Yo4?Mi-|bb8;VtHo56G?JgCs9EDU(~!$|JD&DyAU`v+6u9Rvet z%j9W=f14gBN|`*K7X;53K40|&I!BOtZ9d^CGex34KIh%vbsxDRG>p{WTt*D)Gxo`w zs!f>%5fS#848ELe*in1#Ibrq*R9`mXS-lernK;Be|ApAC{>UWO$}ffL8M$m%rJkcR za*UxJRgynJM|z?%X4k>Mp?rHb(I+@KvHLoQwz>EQO~(B*3s=FA+;wY&(xmF8e;^Sy zwSIk;>V7abZLP0R`nu@rTN?1=;M}g+xY7dzC92fDhhiz)fPVGH@qN^i6Tu)x?v14i zZYE!jL;-jljUH|BjXKLv8d=??yAl8e(h>2vC-|H%M!lnqR##8?K41>Ta6$u}p|uBk zdbo;Xs6?Q1F@2lrdXPBA5HXH9_XLM1T7n#%#+|vWAIvHdF;2KysvtUbM(R?!*7ygA z>TSKusDO2}__|FzAg3ozOninp`xyartutPs*$?l zg#0kmu&aCE9W8)oOH=~7Ylok`^9+1{^kfDKwY}x2?lY;rXD%rPF|`8=9%1a{*X^Td zjhu$XwHot=aqCs4#-=z5kN7bZ9*)w}5eCB$&=}ry?D) z&e>cS7*h8kVu^o`6inhaS{Yx5>Rsx1Ppa+QZ7Pi4E?}BUDnhjEKWKj+Y=YejdJR?h zPdgp3a)e#7KtTbPpFMO*S?vKW#1C~EF|1*1kOvOXnEZJApsgyaK79+}rVb!Satt*HsUeLA{A@qK5RQ#(t#98% zSxKRSFPatX9}Unk0|RP!;iX%ao8GBMwOSMYMjqLCne{LA_duB6uLFhBFmwidXWQi% zoCd&SXvc*aXa!(0qSN!{hCRl<@SMzP%5A$(t7Kq zX*)lrOTN#==SW2lwH!mfCy8|2Tg$?4FDtgEZ-xm}2^v@=&}1O5QR?|xeD|UXLYebG zWXz_deiQt`>xuP2WFYQe4Cw*=_p}6b6tvnw%jc~D-C}1x{QUylCN%*}a_`R)6JaJ$5x#q$Vidi?JWc4^P zD2+dGM~n*0@bUuVp9@fSJUuu z<}_m^M{W}W%dDxg@(e&TNpF2{f~ng76zZoPywD%GA4XEzWR$ktDn9aPUu3)1D>Z!k ziaf4{LImLU959|R*`9VtD-mJ#KLvNX}pp)F1)h$uxV6fo{rjMC#(DR zS-OmV{Yxo4jSQuFM4P=pG9VYv22UJu<6RozUnsh7R9V1tZY)4@6l{*{%J<1-bmnU} z4}ZF7M#d*plIIwhtP4M5^Qt-mtEt?NBwjnndVr~Ri^GpV6}Gcs>znlxbio14X@;8o7Xk@3l~vm>dNY)b;)p7DW$va(ysaI z#8(S|K&@y;Wzz$SKO)R;cqJd<7aPxge~oC#fe`8qOBGtJ6#96ky@^Mc{cE`3TLUcU z#d@#kJG5jG(R=3%S9`Mp)+`tZRB&Y9Cny5fP`eEsSX?8KN}EBVyK{BBWK zM0^Hwa+eRKx_DglSZY>337o9HPKawj!`?cW=7P5MSz=ZzOCk+}^^#L!3%y-i`LSyh z06GCqS&%Q@wTI20L=#-@a_~N)5r{jW7Ph~W+1dB8Ug=%O=*c|ZQ#0Z!1mlTktSr;spwSRK+t!UiRf(E3> z^^stfGt=JLikXM4ZX1aBAUs(lA>?^1$+!?j+}M}WSb#< zW28N2KetgKH|qT^JG+I<#hUo3Hp6LJ4WxKv*H`PF-=%EFDjQh ziIaRZRGS3T&9Z~E-Z1meEDvAujfZ4Jy-)q44sep^ z5T@%m>DQeqng^E!KGY$2$@JfuOaje$iX_>b9ZElkVl7;7Dier6!~{G!k$D(KDJ~0RvBwO8RF;l>UE%$^v}|HMjZ=I{eB7n z-FaN0>fb4KLPy2b-ut?^*yC`@I`kfzwPL~xwb$@1m#3ST;w`T?=Yt@YQaEXiO0qes z?EiRcR$9c3`xO0PAuua7ot1MY+M$8)Y93t;ZH~p`bS{Rlgjl)>{gTFG3zI6~ z^OZs(q`elkK)u+~NYd;1P^(R-7Snpl%EzCA))3_spdQOolH3M-!*P^YK4paA2M5uV z$w3~e|KeBH7+d_h`*n)5q)A*$C)k0jU?c!Ppc@@me97C)iZCe8S8>Jbgsa9vJ3!#> zelmx9kWRi1^{Uu;CVO^nV=ed4GEyXTtQJn(hyX$^@M{ zCs$c~L*&oH`#z%fhP^oeavVoGo@LMSp}DiX_tvbkr->w-2%s@aOzgssCVWsLgF+TY z36Khxc;CnWNi2!O2S>0B6PtL+C_od3+tFa|hMVXtl}FqVfVE$Fu1SQc!Z&j}Z@)~W z1@xK^js&pZUE6G29}$NWY<0MGMhLn=_}b}ntU4PT!@@RMwh018C||kE#LfM54M#Su zDFtvOy`9X|2CmG3*=kJBRG=Y%MU|BAm+d@V=H{naB&~5*xRD2Ak}km}*RF{&;Y-f->%7H0wQKAAo>L^^8V34>hXM1Qj(P2=u3F^KD4YCUefq{h6?@ zaruf+L2N$@Z6Jqf@gL885K zN}0{vX465$?DnqmNp+uz!Fe;m-NhzzsbS_)>wDgGntrEwPpP-UuQ}-U4P2}`6s_wo zKBT-maU}sXn0Hn!dzS6R25IMU;wk#{3F_ew@xr=_%1M!Uk;WU2x^O+UN2W#y!S3|chh(@HiogO5rC zCQX>6PwAozjhvqT{qbLtpC&B2vU*}S{+u7mxdQPlqDhlK0o&S|(ZCc@d?WNvKVM)g zoWM}l9wIAE_5EQ*!XVb%E=F3|S&+J7A9XxElH3=O-I~TLN!{pr%L8oA($fWH_da7Y zNH|*GCJNJ%^t94Zx{vopi{4ZoxKFfmZE}!1E@m6=* zIDrF&PIYL9-K#@-1j-ywI-d~F(RpWBOApmvX?Z~X#K1E!Ph|*Sk@$((y*l5oNBZ2K z=x{*OTV25jIbZeZBPA!^9TslII_$OG0vgel@6|eg7aLxgznPadxo*|?PYfBRy#qC=L{#-(R$t6U^w|!vZo@-sz^ftdaq%vC$zuz za5|auLIUEh5u(e0^;7P)sf zo@G)`M0|MNE@n#foziO2s#6tP+<0fOJbSk2(-h)iW^-9|4hfTw!Sb#~o!U=4FPp^g zt-S0;Sj(q{?nBgA>{8z=G>`c#;rG(%9NZq&Kkxksnx3qWx*Fg0NLc?1E-tE(2zrcM zhMI~!7VL07XqE8CsJ-@Q2rLFVhtfJvyzBbZe9G)}h3c$H`P-0I-`da>k&`a%sR$k3 zpAB|6x3{+hmwwnh_30fgf2wHnW+3{U3J3*HZ8x@$7HY{`yw|(ZQl{y@BVre<<{ul$ znb*X-!cNNvygrL~4?S@PLa;;cH(I-BcI(o8R*8lhEBZ#BxGK%1tnC3Ex4dBo*f@vk zEU!jPep>hrap-PV30d5r>ti!lHG{n6?L)067iF|)2;LKXM*WLlLt4V}>HM60bHxUF ztiMhu0)1he4|qiY2JavRPi+UHI5+*;Q6F%jaJ~pY8s}FqVAGf&X*(UMI`i&48UvY)DA~D9l2p|c(MfK0oo;~ZIxVGyodeb|ik;B* zHCQS|kaa)e3Ep5oJKcPTxb)MxsY!N*RrkzimDA3ao%K6t2!!n8vm@iak0f--x4l+v z4uE4p2>GZgkM=%)U3Xe;s`Rl}8y6b`^>z1BDh)J5+^Y?3;g#V*ScXn_Bv!cseD+)^xP~v( z%LWO|y1vh~`;sRRo&3}vQ+!>_O0qJm?t|;v12sOn(h!mmoOr&=v+|uK$CWZUmY$+> z&Q&xy(d?oitu2^P>)w65zh?LBtu>QZBV5uYN~d-YWb8 zacUbYP&hLaEnZlOs zkpnpM@{@wnKW!eCXU{vwXHKc{p=??v62!b2l3HE&$ekolZ0Sgb47iWZSI=9i_fxhz zpVOwk2tF|aHz~;(0jwTLM%F2OsPUbh(DOyz(}tR5M{9M^uAG|3OVtrcp0B+wuW*%+ zqTSPtrSh^k8bxeswLt+&WNXwwZ^Y}5F&~eXnq0uS;2o}mr809@8r(_RgOLzk7ph_G z?bfi?g2&Ap79xY9a{EviS}_s$6tlRfu}Gwm19b23<^);?A|y0r#u2BsfZ0^1;%Y-C zL0^u)G764UXpZODu4bL*NU!L9x25+>+x{SiWzLgAE|4v>Oe;;7%EG89{I+-AG~29; zouqY}5L+CDgcfJC8rEk+G~Nu$a9tY`$F1?J#8a0M-4L1oDK6lu*K&EeDT};+6H+0@ zv}2$rQnUyNjjeLqIi?+*irqxLeXAl$VGI!%==bn315G1*rK{H`(rlxHC;X*lrR!!S z8H8mABS@KOgox$ijqcU0i32txx%;*ufqo(WU%DSKYa!Ou#$PqO`rbC;o!!=GNGYGv zv{7CP$PTtVL)QJ0R(E{I zJ4-);^aLnRSW!(bA5;?fa?t=CI%Emwv?x2l;25J3jjO$B!n=xDRu0WX?7gG$*;!6s z2ncegk#tM5t| z%YYS)>`?c!^wD5vQF$tK_4N~_dQHNe!DhWsn-0em60SNFE_5*CmjNG^PpD4PK*ZzygL+iA zXEPA;wj1df!p)QQWVzvatN?&9%i}Y4qfVCJ*H6^7p+*l6TyAu~^48xyBvz9xEbnBrZsfAFVhV>QCS~R|zA9Y%b+#+5C_ggHQg1SKGK+!F zY&VI}%sYwuPr8*Rj!5k9ZY_DoiNyV(v>t1s_fk!jtSm zvMT=ez^YYSW1eHj8;;kKn4v^{5G^B;3k&=34zl^k=^T5GHYe)sHQsrmJ~jJ2WjP{h zBL|MEKSk7N?0YCZsDJ26l*9XHB%i=tk#=Ws1@`DRsq@LFZE`D7)`apOsRa8sX1Zg7 z-gk^+wgFm4=*mQwC`~Xe3>-QLVNq85)B7&|RJ3wQEfFA-!aP||ku$xAPl!`@J{2z+ zgJwkcGaQ@_e_@4?0sqYW0?c*j6B>)4Yvvl2-3^gJ^46J)Nt;eZY-1zr49^TCIkq*i zzsVw31)x>&7`3yWK&GNTFNel`or;2^a!=h6f5v?H47AxXyg@G?@wEjFZPMZHfp`&j zF~Q}&)yV#y`hM|nJq4}i_Esrk*m9Y!Wv_gWVIs$Dcnh^X@>U_bR7vtoN#WdxnbpI!{RsI976-tG^n#u5n8$5vxCoYySGBWjI< z4GGJIT)AI&^tZckfX%4KMnMvv_>0X?(QSn^czyu8&?*H&kTr|KH5;R=mOr1+y?&p; zc05Ht1&w!oqtY+(U}Ddw1|a?v#IO^o`HExwbR|Dx!)i!V|EwwxNi8to6~3p;((9(I z>>66N@)Jh<>(Q;>QcgEtsAwWYXSniBiL_K%vtqH6Ef8NUTt)^~8ucp;nU$J1%#S;; zAe|j>8ofV{FoYuXykKS(vl1hzFp#P)&xN)|{wZK0Zr$PGmsrOlBR<78`j^%G51U{Z zY^q|`?#iaKa|sz&T3uUTBq(%kv@LSow3qobieK_o4TI_9jD`F`xj_1~4Fna~y_*~e zgUKLy*O8ZA4O=KVhRc6XFPe2yrJmV;C5dG2R6O6ckis(T&Kx0}s9=O8-QqSxR{K8GtgF6W+7;(wk_w^)U@bXK|`xyqw5LR}|Xf8Z( zk}$(B?>qT>1ZcNSVz_cuNOTv+%N)i@tk3LYdi4i~u%Nui z62bt1PJ<{^-{jZuc^1H|@qLno^{OmQI{=)9+(kIExIB&hb=rlpQ4sYXz5qQ zfkh-dPIQF&ou*AiAyEBb;0EFN%XgF>r>4vL&QgALYhx*Barmm6)K`ZP1^y?MB04K$ z;C-$&PxSE8DMaU0gV^-?uWcT+eatXMOj7cx0u3~cXHtXb#I+H=u+kUA(fm#2X8iR+ zlX0tk&j`_`dojEErY=)oKIn7ZK?(S9l5IEjjhfISZ}0Orn7hn+O<$8{N-(9AAz6RV zA^d&=)9}9h&)3go*>KM5qZK!uWPyH2w+6+_;)pW-V7Nsl^$&YapN$Q>%^gMB812hh zqX;77%*^0j5-{h}+_}Wt{-B6O7Ge!(yS%#^dOO#*urdeiHI|B;G5wJ|Io@3IbMWadSNp62DT!|P) zU+@<8h4VaZZ~gMfv5X>~_3%Ofc9JxRIUn#7tqwOKzMt>Cl*aD=2(U>YqjA)4Waj^V z5i+X!7cOyM)aBSLxv+#BTGTBc!n92(4k56m{_LUMX2LM-cfnVF zYUIS?HG<{4RwVSZVn8b%j%eI6L?Z^i$pubTE*ry%gUU6;jLaXg2ZleC(+q1|(N#38 z@CHB9>Lh(n$wTCw*!+n1_X8E5ReW9L(NjEGz{Ympwtu<82429Nzc|8~kRcp?nhrX; zgXwS|);mv*Vm%sEa#j6LoZffo2b=zlV(Gu3;s4qY)*Tv=ziQ|h0`-8ZhhgyLZLu>H zt?*mXMrBWa8Qj?O>Ct0;*|e7L?^chKa3a=m<|0?TR`D!IQv_VN)%^`Y_>Rs? zT~Gn^dQGV!BZG8wi6EZ#hDy^9qkq$!w>u3tdQv^68l0d?^<3%r0JDl^r;=wDT%bff zEr5mlyn_TkD4os>6Kq3X7qi#3<$fN5DgGyDxP6@vsqdFl(L@wbvF7)NgM$73Q$Iku zW|7I`^;mpI!)@zR4Eat9V<@BB;0~LtL1O6eUp2f6^SmB2NS^Hw9zh4T~ zFf=_E-VMw~kJx>fA?H(oi+~Rk*o=L!*|TJSX$cK};di z9MllgYC}N};?i-Yu{5FCe-0bf{6pd|hCJbFJGr0>XP~tcmXVX3CzPn(=#Er}6*7rl zKt`mFw@3yG3RWc4k)CLP^nybNu&Bpb?48Qer2#-FAFy&v635(l*uNQMeeyYnot}F_ zCKU|+LvrWqK}k1?ciSUB$q<}XO;1%*t+!!mRsY#Q_Dph zZ$O%LJ&+`IJ>QxPr#;yEM>3&WBxCxtYk0eJuzg+#Pp!kxgN5x^+6IZUjC6mcbWa^5 z8Q<`bI&Z6Hl~Y>Uu@>)RzlU}8~UQBr&na#Us``2ae-D~H!*)zvWgfM ziq&mu&!w|~JymRj;|`3_|7ch_XFLIwdvB}RwIHm>sN_GZkm9`Vck(SqB|#4YO&D>p z(cK>TA*IE|lM!Ew=Bpz?L;B63B=Q*i5=%5ytx#npu&Bj~R@kQwq(oz%6KU7U&;B?- zDA*_DxrZ9!kS+OWA1M_}ar#kvb)Na(Q-~ZHqf$!&s(wb1)}bY3FcR|bz@Hy)JPs;B z5>|w+s|TTf*1$F->(5}nE>v`CN{CLfFJk&aYP!+`>tl$>Z>e+dQPWl-aiQ!0r22{| z{K40?Dec{|vp#zFZ}`kM2rR;o9QtN0m*~D-|K<;U9)39kGe&>KUyp=;zBR?gWah_n=Hae>U)}rCxS7ztp(|A=h*5kg$_(lY z7Hh;ufA>qapIk@C%|Krg;o30xbwu$cBnxJ9Ke9e+r`D2%&v`sKZBki4-K90~VN55z z4h>~H8_?&pzUrKAhJraEUbZnCG0MA{+-F%Rw(-Gl4;8)_!A zbi@#x7o)KcMD4%Lp8v*OOx&a3u%slLFlmebOTrT5%YlAbm2)104?O-0R{42Js?ipO zxAq5JX^nn2?A1GnK8+xS&&}~Vv(YEEms_&F4=(V}zIJN|Pl_BsY}^{-@U868;u_b* znWO53{&1a| zJ`mytLU>hsoj3aFCPLZSx?+)0Tcekd*%?A2^BUIBS)3L)PsPfRgd=S}5=`y$&|~d8 z`OW|MsfeXqO&OsAF?%>y2x$=a`$#+gV_?o-aY%?XJ1!DkF2HavOOK=>gmYR|{&lHe zc0nm52*CPxpzo4aDZ5F-|u-JIYrjkiaOOm z{#!K0MO8qR?8h&e*zb)sj~fQi(Ui@MNLkQU7<{}z5vjw6=!dUZY<#ylI+@AqBhngU z{*brdtdzQ0YeA0A*RS@W7KFe~m#+Rbz53*Q!8^y&R51}&^FStoOw2n74;RE5ikDsy zoF)6`{WaA7Vi%vhUmgQ)4=wNi;8>v#|JvH z^UMbpaUT4U_|Q5_D?&G<6sCqJi`QB95D(Q66w&>9WOUk%;mU zq?(&i_P-TZeBoXG4n)c$-I2{XQql5MmhcE;_%m-M?&WextECqb z*P8FYls%Bu*SCYS=WdEYn}gVIKmFtaJ-!(uzsu#JM_Io} z?u{-=9L$iY|KWGNnLGJ5IqarGZ0T1z+`haIExv#Litl{&Et{*+EB#h@#YINYkc;7W za6i037(IPEiEEz@O$%4-qPSv`QUh02axmBEO(v{Zg5r3E*V`S25m~y@XgihahFbEU z<*1kgr8kAk$&W%j>bI@8WR%bMGU*m-_)Gxr{)MN`V|Bgpd2qFl7^8#{2p;be8+vVs ztUvYmC)=B>hYApIPj@jUzIIlBr zWt82_QA)hvg6NyNkOkBC0o|}70>#$qv(XR-GE-)0epWG6_i$XCX^@#Y&Z`7(Go3F! z(Mqm1pBz;#$Hm87XsM-*B9;c<*(v`(teyt!F!UKS3NvWi2j-BJm@oF}>(Ng;*$acF)4tMWq8bA>Go@$}!F3|>sM6gquL`J9&*u)%(H^FZ zkz}!(nLYK`NN1br3y4HuvOb2nZdLCoqX$Rianx1PeKKs=pT!RB|b@3pQiyHg@-!STT;vPL-!b2-ht&YfA;8iw#~4BfA%nxHbI$wv}jey&+$u6HH6Sd)w$`KFWBWaR{BJUQPv0yCkJWe zE*+lEYTM1F5jA0BHf-p)^GTyA^?NlVZ{QmrNp&}tBpqumR4JGbV zst^$gt0BESH3(1`NOMh;akiZOo!cKl_MlaPl!?HM&wGcGJmg=+o%f9l{#jtv-g{$% zJAotNYkS}?Yn)Cv*P)e$`@R$8M?LYa=I7-0IENPqxg06B4nW9|UxE=u%qYQ*3UQFs zuEXoKhy9()fN$M_oE4twr%;9VY&NfVd{G(LX;p6(!^;_hS*Jf5nSVWyzOM__48t zJN`HKg;%vM0K957m@I4beB^k4S4)xF|841g-ys0zrj`Df;ztOIWf$$hl{dITIzyI97M@eQ4emzqtMKJLT zInD#w=6QVjur1f?#SadAbqZo&V1Gq-c(@RM&E`ZtjK4H{bI{iL-$ZJyKL3|!NTYm( zNKMZJVRi()!Gl;9kbu!aP|4}wCEh(3lyW&xs4W{-cz(nfK zcGR$_%1u?8%VrSq29R?0R9Q@ku#$@ZR0gLP>0Xx6`IHu8Q%bO}^D;yYtgQsyazFKJ z*Lwzlom5;&;z`5?gE)NIpC26?F6yVS?7nht#BQgA!hbG9NsU*~R2tI17s3bOVhA&7*`;+ffOv_=H%`wh7NA$p_=G?Ysd! zJWc_JuWh1Y$$(;P@jeuN0TUM`&E5gGVt)6#2=|A%p_?I`r>M%CvP`VhKp)tSLqXw! zi;ySi|6&3D#4I@c`-tDLyohG!Gqcf+SHvG)w07Wd#au0msNd6hFV3<1%)6hRdq@uNscbq3cs=>wA*V?_`dG*+^7G$5fx#!y zrYquH<^H(Jj7ng`21?BTk1y5AO3QQNuVFpmS=;5@`U;~@ev2CBk25W`Bw>vnx-Wi6 z!1u}X@nL5zJ{g$({NvcOUFWZ`{2O6?a@Qf@KN#=$3&Rfb?Q_l(${YxMbSld+5F^%= z@}xwzS#d`a4TDhUqe1^!d1d$WwAx=yAedcWw-oAdS4 zimRPw4X)}%30hothc9t?O&nj(lx5dGL8Q6;N$c8JU;ikMw2_09PzKsTd+yoZheO*d zt=p_jT21j17soQOlnvuA;7Zp_3p?n4UA=m7e*$55v0$l|0uFY7m6uyleGF>_!%HyY z-#eZtO>ex}a%6=MHK*P;KXTaokp~fQ+wWx3*va4LYxqs%%E|q2h-5vn-wPsn`w?$Y z9?)c~SH%6pk!M>2GGH`d)d*WXMKq)9F`KU!^*|21=xenB?$K)`Kf|#5jK9C-V<=m$ zC+=fFyL{{2lm6hL!mNioB1%|ylub)=^kPs^s9uY&hjqWmDRwG-9ooV9C$GwGTg@K_&6{{hRgxyiVTs+m{(g;46pt zMPrA{<1hW6@1V1&;O|;dATn>LEeCOUv~_rYak?%EMhxhwWU`eVKXRK(IppmjR? zt(^UZ0O*UfPBxPv6FD+_W2pDdm$_i2u)mj&r$AV0y^jHVg7Bf%l+kEUaCG_va5^d1 z2`6RV}=_;;iRJ_-z4Ms#GASYHb_k6DkG^RSeY zp$mmr&ey{(`CJ6z4GsiY_4vm-UTkpdVh4bCiuwEC}dBBTa zDb4CDLdl}1LB+dZ>vT2;#H+0vK!k&Lel9Nq)5(`A$_i%l1V;uTqv*)R0Ry6Ncc z6ZyM&1n{<}o%<{4LZ_Nh-y}+A z27i+7Vel*2IL_+F8KHSRb~3RJ*sE8*Nj7(BG?3L)pI2aWa!8$@Uw}gRV^~hQC{@6w=O%Y0DF{NB2p~p?y&@LKypIl)p2&I7;`8476`#$b*Cvs@ zfP=v%p^WnRv5~3S;kd)-;x!KeNU#^n^qhZ>{cX0!T2o7ue!WSx^|SDjbLFv4o0nRb zvZeQj&f@fzvU z>fTy$Z~lUc^lt0tes$IPAk5dGttQh|JYbASDY}52SY?L7$G5Uy3runmCFFHNf@%gS zRU^z^w+c9U_{Bc*xFVzi?k{hJeBSiU#hHDB7uS*I`R?56B*(?BQ>{P|0^1L>xw*{l z(K}DK$C5wS<9Qz)9{=b+aA=U+=Z1HX1khxHy#`!!wkskYKcK(qOO4LBK3)-nB0Q{v zUvvLjwALX{AiKtp)qKs>Y{s6fvG!KnR^rBy=tu7xzJ&EOXm`vrT&6^&=&GJbto_G{ znQPO_9-ZZ zq7H}lwG~s9_U8fdR%mxvkC_#+eX{AVUou&27F&b8yK)J;bskEKdg-z;~HcO<0bB<`kPU(jeRQe`W>3mC>`(B(eNo(kao*X>b#9= zI?LU=*Or6KYF~BR2Y9nFIA}SOMC{>&nd8_^MnSRbv%)sS(reDUcp+hbb&50ndEF%` z@04C~HL~8K8msYbl~V?vCV7%F5Uq$jw&~uoYHwsU!J52dyMz~QXqEyxhRzxhMI~2S zz4l`W%cHWi6b?r*o96rC%$1rrQ)UZSYMbSTXb?tjxO&`1VPjR+5vtdg0LG~%V=O1i zS1{;5OD*|;$|l^Co6SQ5Ej^0itR-glgYVPRqlZk|IgNH|m)T~r@}mUvU_Mho0R=$EN^!HuK2U=8GpQ6{}b&B+N}WLbRq0{UH{r# zoA)2@;`w?c%GLv#EYY%9_sL&UektzA@kUX~PXi8h2RtDaP`q8|U2tp)j`j%a>-Mhw zdrbA7mp{B`Fcl=iaW#qe>;%AhTd-WGfLZ1BT| zK@QcMvC$8U#i2q~cQQzBRo~UI*L*XHpGZ-q7YKa&I#>Gvv4QJ*cqvHqvdAZk9yB^D z(^i*8XZN{Yi~$@^GU1tv51y6gG0;;|1Fg3gfdpsQyvz-cy^q4 z>af~Z;th%CHa|BFZ)Q^Ow;(7ay2HzBo@Vg&D^Wk0)A*&Z79JKSX!*-GsK;-AG?uS9 z6CduSiilJAR7hybqPcI}BA4D22u_8DXD3JQ5-|KB!h?cNQzChfL;qsaCjG}kSJ|Eg_0Z+nztJUCSO5^{qUldp3X1UdM;&hx-Cg4 zm>v6|8LCkzrR#%+U3Vx@EE|*CUr-?^6{A~ah+r$qt_6OzZL+}|rB*&9zFku3*#|mwKm4@5I_o-_f^{U?UgYrcWh9b%eiQv?&Bb~5y=(Y-A&s`C> zMXOg2UlX1--!SsJU1bq0YkAK`AgUMm#?kYAoEg2eZu>!f`~}(k%z=ulDX^n{H(W4S zLoTzWI9R?nerWfTg;WB!7k#tmCF1dlkC3_=UJ02@JY!~%r-YeVzq^KUOe6sCcdvDb zc-&tDE`8!Sz|ox5?dpp)qP2NX;&S~3l>$3{d~9dl=12@rLtU@V$0(aFwKCoNn?{i5 z;;!Liu!tQ)h=xHK*p*L*#A;wRTNagG>zxczAj?!HXZGo*M=>o1WPezDpjxsr^%6Jy zC7;gt;kUM)H|oiUI{3tD)kzdVFhyYPLmCwO$h&i``gHi#j3Q~OD2sEYSi{H1^Um5J zL+a((!#=^Mg=N*kvtEV?qXJVCaaWHm$`R_{kOpt;4YTZ1!!!%nfX%RWgS}P&c|ah) zTG>j^V($Lx(;12c+RjeyZ3-*>Z8H*f?kmNl5rTN1AHdKk6ztKKDq-WK3FB77JXTl& zOjS$sr;hi0slB0u3_b&5U^rM`kS!t!|r> zyj~oOBy}Llw!g(@n7PuJA&i4Xoa(y6^A91#8I7bX4OQs=C$daS(8n07n5A{Jybr&U zr_LE=pRfJlm&Kkd^I0;3He00b`2DA%-EJ7hLrxzn?>!(GD*5j%JH z7}#8omB@;s^hG~-;R1=}9Fumy6GSOR-`E?Ob5T#HwKCO}J1y31>~LHhI?e3c#<=l& zp2;NZdAPe<#23ETYLk68aqHJ6=U(7i{YHlxcspoE)0{26rEg?{5;wOwoV1_gI?^EN z&2i_o_phI;+>cpqk39}}zb`bNxAMal0XuS?8{_438{uaNQROtgrIVpvC`D3&4l<`O z7Ty&T9VDq_dd!aC$A%x5S(?D|{E`0jH%xyo!FT-d?aLm%FdaS+8xct!1fD z^~X_(b!l$a*fN-F(&2^97$l#V|FqUYzOR0oDTWd!Sj^D5{=3ldJUTtl-qnSrw9P0f zecNPV?$xUNqkokg%t3YDmE2bt9qTR7s8{F5Uexqs(v1BIVp-HP*vN6~OCgUJSW%6k z%1L4Y5nXp~(t;5JOl_y!ea`V625*Y_>^5@hWYZ$sm1c@O?j;sD=XdCk?CutgS;{Jq zu_FryMoRX`-6jGxskW3dXz@-y^-~YNdCchduJ#($YqXULp}e=)a-^&7&q2;0BllNE ztFiesxL8Nk$DqTQt}$-h{|@~2x^FVRmsjd9TXA_R7anrlYQ}FT2r1I%e_n~-`ulDt z6nTvT#}}-bmBcwKze$Lb$W({S7OF3A`pOH<+5j&-_ zK9P?rGut%93DbE(inVRXQj<+eSgx*~?Q`ZxCnu+D;M)X()4JfquX+iUH2J}nZ%OF;rHBkqqVJtR*xM0vdFu4!v0784OKhsjGu6rmKZ*dewRq6 z?DZh%dt-BezcXJEDp!S!y~(vUX|ImoTM?FvlxbLy?l?v`ua?s}nV9#_CBDCY z``75%tov69_1w$qB__!8&(;|i{-!}46LX^aCO65=kJ`_$pwo?ACI;+ZvD(lG1iO{8 z>q+k8ECyl7JY;`UmA&75z$YDDTDp8CEkR1m>lJ%NK(q0x#cBPudnT=4SVRt;Urfx? z=I0E^L80+4w}BDJEeoph8J24! ze^fU)F@G%iu>DjigPDb&VO7aZA($vtI;Q4L^#dllbRQ*ouv8p99f^Ra z{{y(3;x$ZhCM?s6GBnXUrPOMWbt7Yx zp~MN(1E9ku1V7KOrAxa1*iPZ|f|N5zQJ}TuI1HbF|IDN@qSm*~0+ri$EuLhb-b4#U zYdvc>xRY?AC6iwFiJ_UN_F*wNkMd6^O{E@_dWGV(^}OjvoZ+Hj#jlqx!1BqdE|UX1 z4BLq5SC#>uZOiViX7ObmU-$#%dvVAIztbA69w!@VWd%3bwq(%NJ`I17WP-w~laLikIwmQ8xpe?QVp{q{P# z4Ev^*&+)xh#P^$twRW4-OVNQBsW%SOpSJiS6!hpza@{r6VZ$)r+&L2Ao_Ubte0^-J z9y6*`Jk+l7d<%yBns~jk`Zr3#5(eR!dyNn7<ik6BpLb~2)pF&ZWGV@!>FB41(DF=3~;eM;w>u&-(pHGmq zj;W%*qHJ57i>!RJ9kdE}*|#kN(~Y|x8md1uRM`i<79f8&09ktxad9>b9(^!}z0v8- z+IDx<4`+$UZ}f|@M%(&)S>!TB=UZAf+1xL;N`^P%O%lpJdw7{6+Q@bWqQe_pUww1( zxgP04#(RikZ$ZPNjv$bh68s9xGdk~{mcK4Yv8vEd}U5tpO*)Dzas7XSJTBA9#nsoN~NHa#vQR1Few9_7ya9X z!<~u7R_Lg94eDldy(9L1YcuerWrj`+|Wn z9v$juNM^QdjxON&5TZaXnsoewOqcQEU=AA0qzNT`|De!lrhER@+rxiW0BaeL5P(@~VV~5XYgX>+O0a@^kMkL$$ajeyi__IuMrzgtGL-_J} zOaw3LD+FvyHM&rd+sga>rdzcP(1tgcNpC}(l)MxRK_?&Dh5he`7zau5r{Cn+r*`BzHXETSH*!Y%DNRoWf{v%g-C-=CQz zK}zCSdF|}&f0j**Sg$lbhpyeKgv&cd@d;=t5N0&*ey|#=R1X@EAd}{NbN;D!=`lXx z>HMql^PRm>@9(Vm5?m6YK8*~Go@jmo$J{FH!x!g(j%Pp4R^pVRX14p91BcXPUSBmm&HeM9#Cp9kBLrPo z`gl6vc#KH)IRu@{=eE%~QYG`VurlqXk}qu7jOV`IilPT9aqlA4zO`;N=J@r?dwf!W zChh?xUSLP`&Y7bJ^&kQsqjPutOR(gT$vh%9)lE1l4fX5Uf{+l5E~AmPn))y69Ct3( z<^sL?wi&cu{2b;hq$Py2Vlxp1ZOuvlTvVdB?gOvt`CTO2N|}bt4cD)r>YrCDoHdMz zX$Rh~g7o&&yJ6nnVFWnC`)Zg0lgmbsWZ*ODj*{mrtXNZ9klxG zKrq!IZ<}MqD_Ska^s89gi*%pey%OMvBabi0K|P{bxMUYT#(F^|@%}1d64y>Olakf})>X3#Vxv*p^;!bO-0LJELHV$+iwxIkJXs6z6!Em(faWjoZ_Rsygl_~v$ zxG@0E(BVmw#Tu|-pxf915*x?Mo!x$iVW50eweC==!&|VP0dM15;P~fgp@A1mDc%ki zu*;+pohqK41s@{A-iRQeN3AaAT52-UdhPT^;S4ulg%k4js6FoMHMvPL&~7Fvb@nnp z_EKo8;~mbf3=b0ntwdxu%dO^P*^8_nL+ke!$olNAR^u7;V1zuZ1jrpOI`39!V=2rk z!Z$s6uZaWtFgi1xr$4zqJx~GT55Laa|3%(gM^)K=U86pNgp`B`NJt~n-6hr$8_dDbJ{yJxjGsfXBpND;O@B6y1SZl7i=B!wO z7UXykntuwo<3+`R^OpC1Uz!me0ebP!aG>E7?p&q7a_>sH3&0j`}I#07vHB}5V>9+vA6r#jUQR3NlMk)m%ozDzO8_vkjj=pTpEoL>;D8c z=ussFwjbmQ;q+X)L3*0o2H=NoP@U}W?hIjWI^0~L?$c}j#|Y(OsffcP z_HzViufLbJCSb!INP~OZ`)nKvHix~q+1QDKf4Kmyw!@m%*TQqv*G#IGbKiWK#Rcyu zgI9(7gDBlmaV-1KPwJ%c_zg*^F!oE{#xGQ@8t9`8d-;2l{h6P`UhhVBa%zF?gx{H2 zR`C$3mLpL+@TaTnDK$*zGG8)F=5s@ypJ96qhV$do7cF60ftHM++_I zKGN|_CJ=Lqf*AVGK$dFzL3Yb2QP%M}y+X`K`=N~l-7b^}0Wz3&jVL1Q>vD}*@I;-CF5vc7Q`?HZA6kU0Yy6X11MFoCY>TpO`MPoFj(SYlG>M(U0gc+ z#~_cswWE8GO<}IqQt*tJCk&n5Q+$jjlF4K5F_5t#xoxN#YsLTqvVlrsN6QNx-S_c} zCVN2!T+-BY8dq0Oq3hHROhGF;w!1pu$KBi&5NH<(d{ScQQ%Q*2LPwfkpse@%bw~)7 zeo@RYQ6Y4+dW+E$P4(kadb02G&329Byg#|dGJL*X1)XkfOZ!i$;|Ok1S~Pxq8mvdM zs5pOv1waU3)b32C+^!^3lmspyIi4bQSdS{VX_0Z{Z|sQS>Z{s8U-|(^v}Q`1`528S z?78euD$g=#gy(>Fc%_~w=F9i+l~&qtJy_k7ZK~OWo~C|uk5>mWZ(n-0QOMgWzCV1;#7|a@5GV@uM7h~(>_2K)K(^;pVvCQmdDzq7Jr6)uJ zVsI+>m6V=yc)IIu&;`+uwD0QBSKD7nL;Gs{R4uPKCLUnka^lha2^}zRyR%`}aitJ4 z)xP}Zvp7cCI34X2Odb^)yF}ru`nU)32+&HZeM|;-bv@{Q63DDfr=Ed??L3aQ%W0qQ z2}5!rcYhQ(9k_ha*8nq_1;QjJhxC*-n>#e%Gu3}hf&W9Q03qc(pLD)Bd6e4Zd`ra8 z1Pj7qrh6|I93Z*WkvK}T$fQ8=XMZ8P8klprkm>kgFSYVQ$yseT?MUKu;TUdi4zk!c z?iTQz9P7PA_)V5okWhzEP3k1#=c1p+ypO%JRz6E({EVa zA{S-Vh=^n`aHK?(p(gykiss_o@tZ5glqOrmz~$GX$s3rk-cF>bJwE%6+q^x=NG8a=Dju}0e?8ITb-$v0Y$S_rtV->ifclP3 zlZML)u z4I6{){Jt{y-@n<`N>VFtxg{B`Icw|rj}I{Z0WB2|)=iw-?#{eq?uHSmD-hDSuTm(k z@4v-O)Sramk#(5QvmNWS<{S(;BQycKRbDC24k6}GhthJt*DX#XHTE~RxL8GoUkkesl&F4riFW@eB*=dsvOn_R^JLJ6#k&<4;dJgQCwNvoFN!}e@b^u0 zUqyLehr{eXBt=1(?jIrY#h-y(AVg+#E|&D$w}c%!!-xwxc7#eS@FQtF3GMiAW`S0b z&B;D%zGexRi0@-lxf*}+*_c0HEFAvtds~-uZZj8SfnUEH&GxX~neqDJZcWH(m3XVPIh0-i@nV_RrAVNj~}g zcYlv7*LcOy(xt4V^tbp1OXK|rCBlAQvoChT^*w^r@{M+*EgaC)7WbZ;?t@-Zzcd&~dxerI5&T{b zQw9g8Yo_Pg$T2WK{{S5AsPo$2XD4-ChSnc@A2k;}_28k5lQTJ>506$1F@soTS4K9+Inta5( zp$toepau-WwmC}kvg=mG>^G_KpiI+Q8fs2%pRaIr@kK(O$-SL!huawy)EOk2lQo7a zlu@!J6BS6}ZVcM{d_iTL*J{2l9C)L>a0trA<8eet;zLvf^J}UGK1d8@T^z{5^qE$x zBL$SLWgUGlRvfdX+29+(QoVTf0qxq%(yy0*m4`+u>NhqC z00*3Xh}&}en)wWJp~I*VG7idGZ$>!8?Ld8>80Tfga}XpCtpyOXvjC6TqQTRXn@=!$ zUEgKFHDSOQXz5#Gbt&zZPb0P87~-{VgB5kUsRrpaV6yq1)x3 z)sz8PFEa(KH@EsXfS-aK3O-pjC}*r+j_p2*?I$qYdj&8EaTuoZN0U*J*TDq%u;d0G zb>=K`qm%rri)w!Vp-g2#3r(J<$Sd8#ATAopmov2AACe)73+hbMvLBuZLdE$4j@Ey6 z0H&RGv@QgA<9((_oiQ&y4hNx0rc#gso85g%1^4wc5t80yfhq)ltk#|lS=)DDex?tI zinM}W$E7@X?y~bC=BDTuw~SO$I3&YaViQM((pYm+LN5^m5A=1uLx-3FFp;Bo0@j8; zYzflcNvpTOYQZ`Q(=a>B7z_%t}ChcIRB>$W7kTK6&weA-gPj^ z3u=9P5Fy$>%DMc$BZ`HPD-)}pH*Pj_X;)*g7L@dS2^g6s()Xos!EY*2bHa53#UV2X zgASgr!_Aj1#g8fTXx?UV8-)>lN;@)|r2PqKxl22~y_$zKD`Ww0kGr;;wMyh(^(Anr zqXeUlJUk9RWW@(i_1~QYm@~7(a7PfmZ=N_mqj|hdHFyL^nzQ>difW`KXW4qtJ8Q;W zU8EVb9A@f<9$m%LAGv;u=}YBKxue%#28za~4C)sj^_d-K+&!95aVtD~t$dJIvJD3& zRsr9j$p^P(Y)C-`;7o8O3=H+Ue&?yFnvRUykwBjc*Z~aBQVjyMYr;&7bZPY%xrdqzG z5%4@h{UR>k0emkaxrDWeD5q-gqVCQB(aGo5D`m)qy;P5=s#!bh&SafYE6+QDjk^Z) z>pr9p`li*C%-#P4>}G&LV`@a#9~!##nzTPUJdH1y!IEGTvOLd=(dc~n7+^kNi&yLu z`mQqkQ1WX-kzZhOl6#3O&d7&mldoO%dTXE?>*;n?TUNJx)sz%17G-yYjL<_d_bZ<_ zr?cQbCD8r&&#rYHE5xidx}E#i-E=#+l3b^2T;{<}h+ z^w*4t+>T6xLIe&*s*(eQvwB(+Z_TIv0klk^?$}eCn=$Sifp=`GgU?HghdtTi zN)}${Tv-m~IFPgDzg1MzO9b{=e29hXVVsDh-*N8WMf%11F7J&>#y`tHLc*Tfb(wnk zjqI;ia`%2TIVP*E=h{AAXm;}lZc3z}7U_Jx*Az!J=%D2|3hhf``$5<=jhErFU++@u zzUzkoQJRnEghClcvqiq!r7aQA{7Ns!f2?vv8)Gr?y%YgJv;S^MV*6JLm7EGO853)A zUxtgO5`Emkvx^&MM-)hDaJ)2*ZN^yj|1cjrPoqIW;8;9PB$K2hs=R3}{Hqw`U-yxYqQbLyP@|;!1_WGQ`^TPEF=e%t z&fBos6~F%{Y*J2EtNV>TPozoYEk6 znefhxc+)1=O-@cO5$IY@6FcAk8T&XR6XR0q<; zS2q)H8cb&!?OG7NgJfgq2MEp|Ii}Y-cOvlTOLIM&7#B+-l{TRvr|&uWB5~IhZ9G`D zH3YaC>&nF0OL0Rm+4I6K!@XIv(bPFIX z2pvzB08#8j;r4V?Q|0$-$=|QEH@D?VdOy?=j1>iRITXgv>)Zxp7c!YL(@gd$jZYUS z(pz)hhtlPOKR;0ex(Zzue84%eK-V6-T_x?|R1Yg2i;jUJll0%t@D~6x&m2tVA+WjjhA$VKISWN zwitTdC(M}=;}QFZ*qka1S}G(JV$F+KTPZwt!lI%tZ5Y+cBe+?jBhYnnE-O(7-p^rH zQ0-H$4QGh>jWuA86luf?>Xi%0D8hYqwIt1AQn)qi9c$|xe_??#CqTCa*Qjec3ls7{5w4&li;niqKs#2Vv%c8I_WUtpMkYIQV)YJRdM~*Lw1<5+Tt=K5w~4pr zz=josJS%EgguU!8J_k#ErUEW^%Q;~+FCIxwWNRvS2?StqXV&OfgnWC^Uq&ugnLnUzL^`)mm%&SeUGhUt)VHV0q&+i!(ktBipzH=TkwH|4)Y|?^Az?S{M zad_8MSN8WDKjF8fX#BXUdH7@VIDa7@&awE81h^v@%5i?~A^^^Rz0Als&t#vM6j!n- z^u;5za9r^y5c0VuqKCZnfFk3Eto--}C4(r#-E=-7xO|T^bB`P6C7eF&{U_X222J~{KhP5{$Lho%WLM^&8J8% ztW;unc?#?Hf7aPmvlQi6+vsEji=vDV#`ad;*QF{l8!%juza#RQ7b9(HY4n+792+{B zuamE`2}1DS4YTk(m1J@K4#Bu&LG&g*5|cI2fPTce_`+Zrz#%v0Nl~`|9>BVX$PQNP z_7?wKb>b0BOv(3NJTL%hNxRup>_0#6@n6s$APmg^|KmS2T%d=s zv+xuYDIcZy+O@h3Vtlmo`ogNVPihq+89>Be4nB(dUm&Pnr7SsfH=+0~1+6#hjv zY4A|2`UyOai~EVwYMWmF_{e|+CFn{P4|BP^BGPR-6^aP}+w}>A*KG|Qt25A#Ync4* z90mKqe}kB@+@UuBWd5^K-qF^wo1cVwvAH##|3o`95HoVf6g;>&z&s~z8Tu6WIjkeE zH1plW>P)gAR}|GbqP&h+b!Fg}m90nk|Kj1FGZyEHhB+Z4JBklGU)y9RdX3i9+3deu z{&C<|CjF1Z!_MV8r6eyz2`!8?i4s()FDnKAzfffc2`Sq_ zD0#+1I5AkK5c9?*W*AzS`}sX;_cwMI6%;X9d@dpK2qEY$7(`pg`d5ns4`wXr#9V)@a!F4oG4R*EPbmG@IsJBQN-qwW3Pw{B37!iVm2g5&F zd|Yji2j}4*Jo?|Sibcz$aH0od=2ViUsl}g8X#kb{Q{qu%IwAK#XttqHxmbAE2{Jt{ zr$bs$g!4yQ>PXneQ~}q`Hz8u!tX`NpJ3FUqc8;vuYY_{0es&@z4j`fC5b;6Mmpc#s z-~ZWF`Hh7}>WB4(d9Q@U{CDyG4*oyMA~R_bZuLg^Pe@ z!g2v0fG{jDT+k~e8hync$&6G44RX+8`grj=hEW6lw!VfnZn$(VwMFPsv;P3(T`@G8 z4@BLZI7lk&*S2IU_eL-PbUD$|;p@=pqwf*%QZ$iquq-XB+b;n{_1(|-O@LFr0Cf)| zxo4m?L#U7euFR{$9h6)9llSsL55=PIb#LiHx9;Efg%EYWY*weG1F%_nmVcD*_nv~1 zq#Wq~N^;sK2VH?bqXeGb0Ko%&l4h#3rhC?q4yf@Z_bzPPn$P8m@adb|6T@NvlWw@3ZS=76TUW)hm_GDD z#bi+!i1cI&^Fvq$fi9QBIvMEi3%jW+fAQYuVBKJOWITz>`NaBzWHeU7NY3k@5is36 zcuFu!!w`ai=v^DAB7?d+)vZ>+0ASl7Qs)ijYv6FyiOA0G$-<>VFNOo3hV*sB6=~ebRWF^_4M#m=bj(*4|(2FLK5$CXJ{ZmT*zFa z;5Q~pNJadRyaClH19o6qYXcQ{|2jC6+GLy-=bl*K=W-zinorms&}s1rf=18xa-8?w zY5rlU;-DYd2D&HeW8)|SW1wgIWHIW2C*XYdoaRkDrQvys*0|`5%0K6ibel{$7eGx~ z^Q&~?Xk*3ud2~Wv2i!09H5_>w1ne%}B~GBW-*0*+ERg2ek(>ECV~!?*7lq6Z!@q6C zXelo7X#}kc8JpQ8#%|K2a98EazR3&!&{>Sd#o_1;NL9|=u3j1>_!;BrhZfmrO-G0! z5x?O-Kuz99d)flVrCENHyxPQbh9BywljxPPNcNpp2kS=Y;CaYOf^KL)p3J{6Fh ze3@L|O){3EYl3|ZR0k*(BDX7*#Ez3j_ZGZ-&}PqGXysOFelyt|jq{>^Ui^!#o8bG* zI?3_%gbUa`sy9b4NCY2ZOPnz0;V)_RiaQKd3d`EnM_BGFW50tCKRe?yzNxH>Z(5YM z-=BK;YSK!0gaUc@F-}v`dF-y+a4`&NN1YV|pvP=^T0Cdxf+jO1h(_{wW~vl#YG>}w zz2kX|Uf6!5GLovkf^td#P|@HSf_5cKiFn21u(ugI+#TP_VB( zKVW_g?YB}(uLZPt^>VXUe&?qCfJn2l<6DTwY)b6vXpwn5H z$1Z*VOEj2Hw8Z1R;H-VjfO+sQ7l6y3Iq$lr2c{LKR=SXWTzcAPx^PIZfJ);Zp3-%4 zS;*v}BA@GF`L7U)fH?hcOD2IYZ$a%^Qx%o zE?FG#>%fD!Xt7I!NdUn>#sN-T-clW&=MEWgTZEeuE0QLFw*!Y$aDf7_Xt4P!ty~7j z(Wo~Ic$&{60dYwSPf9;Ux4%lc?Gw0 zD*dSrB?`1-xMgs$vsW#Ekoh41=Rd>StSsg=PA{GO7^Bmfr})0G1KG(cvo? zDh_2lt#T9ceSyZt>tx~%zFR&e7*0ZNak>Kf^D8|oPmSs44^mhg$ATpgT@5OgsG6Kb%}_Rx;*m_c z+Kk)g5kfYzdvIWyJcP#LO=ZFTilI5VpU|sQFG7UA%Z-<-UqD@DNaits zCf8Uu$L^H>gbtz$G7gkMqqzHa)R=J#Ey(D!kQ{j1IbU47B&gmDY`?SvQwr_0Zse+5 z=bUt7sF4^bkbLUsmfN(Sb~1m<5K84xxCJ@0{S%%rf6}S*)anpIwyHmQ9?Un*I`fo< z`0~1lxE3gC&FOt2L`rfX3|K!cG|QPJS%Kx{EEGTYYJRl>2#+m}L_vVSwTTf`bziA6 zZzD)o`s= zRr0W+D8ovH$MvZ1hDo{&s0TMW@^v%t9hcXy4t)2w-7UlP}<5*pz1fEWfSiTivJlg*dA~y zFL7voxkQjdB}c~WF1LzZE9O_4@=~cetQAfRheQYTc+T;CL8WBF78HVfSEZJ-G;V1L zs*$)kb;@l~z~=f1RE0BDbE1sQ(YOUeuwNm7su})ZYJ)-7hAz+i3!Pq>q?X4y!KC>E$2=IcNzuW}NneIu-)apiqUj>JPB83BnEbf%Zr z%?a30uthwOx)L#;K7iQG0uv)r(DS`9lgkX|Li*Dn0OztLao5~>l&Pwo?&Fqm?n6Ok zVv^l5>CQq98}Md^c1gYS*g;qiD^iHLb-FvMn&GPf){Ikj2l0Mo!&%}J3~>}g!A}JE zRITUG)t(t=%SS)7UT|GJ_#Q3!m5%xGYEN!j&wz(lhIo$B%(7JS1(YxB)&l_;%=`Fv zyWMKP!5PToyaj~i0B{zPDSmdA1m^as4Gr6N%GC1rtt!ih(Z=dkHi)!e8Q!a={5qY6 z;g*glZ~>i}i|rq#L>rWP19Tr99lH|j;yzw4z>Sq^VCp9~s^VXF3BrkKOlCgaCe=&b znVAIjv3al}bSljkghakrfI!@rM~edu&$`=$m5+Qqt3;k@)aVs)aD)hqO>xco=sicHQTYM{ ziCNwszS>d_9oOjDoV6QTfT-~}wy0}SH-*i4;4-JD(K@^5Gys{k?EL}-+Wbv>q`?=a zhn>o9s+l756RycKsAh-luzT65sU?KY&$>cN|+H*0K~` z=6r>O)={h)=Kdyg^ zgV|}*6%ayHAZj8VfDE}w#PTgSIy#0sc%rrAxC!A6)<$S*{S!3vxJfykxS|WFF3&B#zb*1Zf7B#Y1(Sdy#^y~DXyGr@PdRMq+^(#vgHIXRmxp1FvQ>Vi>bs|9 z&-A;U{7|Fiqp$nFk;rww$RPVQRA2sOkWVPcJyd00^Xl!#R-Cp1W;sGoRR?wxVoncC zFgHWq3$9%qS;3-q8%#QlWQe9al8t(X2j$zl{_0SeOs5k>Q3aNcmnlO_T6?@0(xre4 zn*;h6z{e+h{qqUlNER9h2}8WIa>>2+`e>3F5tn}ig#k9}W}_iVgn*F|gL)_~S^*hw z%LA11f0>5mUWl&`b8pf>=F^NJp3YV7C1K~C^T86M~0t!o$s)DGK-K{zkg;qpej?bu0#h8Q4=! z8}NQ77R5{1HlFHvZ={+&)>w@j(D~0juvW+-oSWC%zN}7WeFGU9xiWz0d1blW(q5b# zlIQm6WOzhz7Adz)9!r}Zl0mfqNp;-n9d7Yl5xv~1c(FAh8XBEWlg)F=0(N#p{fPA; zEOptNth~!)zy;9HS5QZ6eBe)}_l9S-P-ehX*R zst_A2K8F4(m_)p#kb(*In*Y) zn#^avp>Rdu23j>}CX2mF=6yQ7N=Ahheto40v3tfq4vZKBFn5okRpv0gjVX^Ov4aVp zxV{^Z>GcrIuxTbrqFlPqI6VL4;U8w2P6L-RW)OBP5!UG`O%j+>WmP)`_Pu(Ml7=KB__9NroYFakm?fD>{Oz zJEBuF)z|*2p?#ZYw!g#pxNwj*cl<(|$#}m>hBpo41ihC#YTxYQM7OCNhMF&-xW4-f zF6p3VtT_!1KUVri)0vELVgtam57GdzLdWt;BPTVBBf=BFd3y~;NySvE#lKq8H_kBL zZf}_#?FJML3=t$ppc%=@>Z;`ENla?5mWp1jg+#|--p(XBvE+pFUq;?@h*cw`C=*U< zsGSTvme1OW_z~sDNX)mftDK1UhtJOS32bM~-n^o~<8@mKO05litK?m~kJN>+m+7_7 zZBK|2Yk7j@*W)ez<_3e1-?#G>r@?ysg6om#{HjI)M-FYt+YW-O8u)i0*P7(*1(vGq zVIE$aSC6wct$=A%<-X*$kYa8Ra%zMnW$(*g3h&8<4ljd~PhRpqYhhJ4Q<{iDA_(}G zkmaiNs|)*bIvC@v@Gk)}K;rg$``mGp=;ODaF>fY0Le0%;H6`W_xcc)Bo_yewVz%<$-#q0Gs=#Dd(!5F&~$$Z+cjFl3f{OUex{eZl9F7Z2*~LK zJ-c!juuLZJr5T+2KQ+kn?N|Gy`A|uML6(VFKRHJHCh+9`j z55tG1i45RV-Xw5%QKkymjXp1HPSHaL@;iWYr}>p8mCr#G7CFkkb7D#HRzei6W7K$1 zu}a?!Fd~peT|Lm5EZ>s9iCzFRMju)*y3_e#BUcQ{90+a3ih%(zd+wgS?1 z=n>tA*YEMYH8S~;adOf#-*2|BuZ(D4-gR`(5KbJdZn744#T-yt?Zi(xUBD9yxPGQx z3)+N|jq8p$dzs%Y>(djij0;KLqvImfdoq$;MZm=zDhE`6~ z1sJWP9IpI0Vv)>m0;%-; z43(o8g*-3nk4BSLvuYh;R%Tbwt}#VK2u}oD?Bll1=}z~78QIz4oj6UC;j(J(e%v%5 zbD>JBp3AYNQu+=dae3=hD}N%Ly$fSI6llm@ax27;g?Cxjn^LZj+}6hn=jd^{T9l#U zbb`*J&VaxQW>a-W_nVVpRGb`J#d?v5TZuF74uJTb=7ykdkWpa2RL%Lm*8$WlMeDF& z)lne3{=3MQ)3&P(pv~kQkfuTF{(d6@7QEipVOGWEJnekNO$b#XOKBrFtEpjH#_T5> zz*!DOV2%AyIS1S;Tul>6o!WrBd_Ye}!^-Y#jpL&rtc6oz4lxPp?cwG_HK*KFxvhG7 zH%rwTdk;q=L-Ny*qYfoGqOKW;Ec8^yMGqTq!4}$)_CWF+`(U9c>)4+Fkl4{{Yr5 z2T2b*;cW~ul+%W<2+8SUUc8m8NUs6?lwFN)XK*Bl2NOxZa=36L_NYf;J0NF-{-c3< zcX$czca%lnJKvzJOmrZIw>?#@VfjHW?g%WPMCQ_&TIHngz4bp`W9gzKUP%`VQm?E+ zkUYUs+PJ3lUIxMdOhtn&ocy9Me9oTjZE#1aIQRc;HP}=dR|E1GmRA-%e0e51dRiw) zb(eFEqc1#2G&nX}dXI98`{XriB;!pzJWcwMa6+F$mdB>0@mL))QW15VJP}#fOUGy> zR&FHjs-QPY)xKrA94p$r)Bso%N%Wnx9DaIVylx%H&wVbS&jFMyyiK#NJYCkIRNd6T z?~}Hx^@S`cb)Wi$A@7)2p?FoJ$1GSpKv2q`_nbKWL*yWwp&$BFg5gZ@vys>855>Nn z6e?^%wri`BoJ_x3$OU_|uktP(-Zkx&j)=%!C2LifQWw0X2?U4_cm;%O7 zQE;#-K^XBIoS=pZfm~Vze6%tBn9$+icoMVivP`AfQ3Zz=$6V9-6JM0o*QtbQ4L^^< z21h8wLjq>z2EVk#E z&kUMD^s(6$VvJ6|ZqZ^pTCtb*#~ZHB0hoVd;>->)7Zj_6uZhVk3mEj!OFbAQUvL8D zkPxCNd`vR;Jv-4v1z6pl9SO&o3e7Jm^%hKJOsYz3Pix3VVXG8zT=RK0gdhLq46i5H z@mzX){%CFNVkmxY0{?b-PV;Mic4L?<3C{T^5$U(s=)30=*3;XJ-pjE%{s+*RUA*xY z@^)XlaqMA++b&Ygm;eY4%^N_em^q)^8Y#kW5-gL32-({5n77e>=#NK`-BM*CVJ(8x zWAL644d*T)z^-lpupu9}E{VffeWpdRP5$5oSC3L ze9)pJlRkDlTMD*Nhd^6^@M^x~($yz~N6f zU3IG0wWH_D`4);0lJ40p$uuqkf<~8XbhO!%PldrKRN)(Il$95DyQ(+Lo>XTgDJW1z zXbhU?ZdlC7R-R}DJ3dTuX&s_O^7L`saJHpW zUlPm1&5;{kq!@Uokw#vnY@~HIEzrm78zM=c8?t4+V4+@N!+V8JCv{hBmE+IJtAOSI()2oLGIy8g21Yx|b>!3bys%Kil z=t;sPek~o-%tNUTDR-~eaH02Jj>OI*DsgxG3sF6}q*RC*(c`v>6xf>KxUK2aEa+W* zLH^Z@B_aSmKw~6lB$_&bqtV*G6rHY1==lh#^=+czVf*b>&M~$R{yBXG9y=rSszro zf@FzegC0pV-t)VhTbVa7&zDiuH-c{G#CmqOtu{AJ(-w9uP)(XurPWw%+qj6is5#o2 zxt^Rt=>ViUzqz0}rPs*Fo6A^YlAcy|kp1=g?ft{cK5ipR){6ZeXMUJzvoY_r=ir3n zfO%Rln)xeF-O^z=w;5_PAZE*`+gMbC7+U3J>E75%eg!G9PHpcKUT-qf4gMU62W`Bxn8U zog`XnVNss+vr+37>+gCM@!~Xzj|Tb^LyZa|5(Y3HJiscwfbZ1emtYw@_tdCSEUmG8 z&&e9k=Fxmc!gE&2wBd?waF;)mC2IHFw9gLrIC+*&jg7=0S1?J?RX}G8xq-RYXY+Vx zozbX&Gk*{x%l462MMh9UqQ(0VmuAZeB+l)RNvDH$Yd;HAWw)l7Sk0C#8Wu8L?mP;i zaV4R5GAbSv#*W1cWu0=x?8>SAetzEr<=L3BmS@fh)87_ynkZ-hrBqgJRe zgW${>G)(9Jwn$R=IohQ4oLQcEQu>ADp1%bbM=7t2J)8MJ(qtkqEYI;96wFe2X%MD6 zY(q=4H}540njeSk#$RoBUa>oD95AC@d<^m6lTo0HIM*+}zLl@qUzik7HByf4*QzOO z&2rgMF~i2`=*UrWtf^AA^-L8^Q`SmyTWu6vK_5`k9_e{ zvjWYWa+U_Gb0W4k@^K6qktDB+L3O`4x+I=Xi6saQHXpx542ah4l;wj$ilVy2~` ziQ5YgtCu%M!E3!A1h~pzlv)) z7y}F5Tt=B`Grq6Poc?E;FOWs_w*RN-@dwIW(+s1^mG~YziwM@p&F?oDv$EWMV~-|z z9N-qrpt=v(SthZw^ZW9Ju|4~+E5oeOzB3Pmhy?dT^%$y!hkfWfl3VKJ)D+l)<>XUN zL(x8V%Oy{M9)P(9_&7SvAq0s@6)Zbhqe*>MhY9va=fS?cl}AuCj?4t=5c$gbz=vLr>N3_|9wy*==GScNVN6lRFni6EbYdf&Db2!_m;GN403I zDS66;&$2wQHVGbWB>E*s=m~b5Hfg-Dw&LX(bHCY;vO8(faogQp_iSjM_(All-{2is zrhNunPHimow>HJMHTY}Jms{*l-EI_la|E|0dv+X69Qo~w>yW`xWvti0QwCefpvGK9L8#cYppjW?LtcD(A73(|H z3di@+SL3_K1HTt4KZJ#(;wbL)EPnda5Fa(mpj7{^5v$0tOFxN}9e@1%UgNfA=DO-2 zqw#^^zhZ)EFe^3cck9ymgS-WNu3rMBoTQT$ zQ6uSRp=$|yREL3&57C1JOH`fJcZ7#6bPXohIhFm%S;128qBAn9(4QbdWl`bLQ~ceA|`Pw7h1MvmcS6iDwDMl}RS< z?LjorKjxLDcc1I4yr!Ftiq)ZS$McUW-8?4JCM*fIMM?DEXU5*19YiwTYcQuaWS<_qtE*E(w8gZ_q;l9#wP?@8D~0;`t=_4N8JA0 zj*2%}4}538jk55vLbM$Jt*Q7_g5I7HdGwx#->g_{e-{OE+Ybf1mX~C=<2`F5YlUaX z!mHb%VbfCFkPeJ#cCX}jp4{>5;bdMU3HxGpd|UQr9ekH)C@3w=zT^~Rdq;-3r$4$v z5SV!ByG~Y#9z2*6eD42T( z?)^kV3qo#lPfIGTGA}qxVS&i6pMEi~f#%C7uqk_!mk#46xGWG8;Nou6q+$?fA!$3PVn@3tOA}F8!b~8JjRvV6QWWSdq9ryL@N<0_2|K^_` zeRK6GY4mb7SH_|r)~~1G#aKP~1O#7Yy6xgWJG}HFsqs%onnO75DB6!WbW9OC_8F9k zW>zek+@`m#>rJIjFhv&?_sR`Nu&+_Z!6Ij!pDv2#ZuC0mb$tl;;K37a8gZhg_$OqD zowIAJ$I|lRUmD)JXSRFc-(-Tt7s*KcU=~$XH}@sl7#TD)){z9+;H;!Djh>$o@t94zhhXHT>MdXlFRCtMKSc^c-flq+ z@=H)^w7R8A;q%bM;E$IzlzyuAZMjC5Wl`5r@W$fbbKwE_sYRUs&r8vRE^7Ys8-h=? z@^5$sAk<)`$?pHc z+!s$swVGiNADy6Hn%lva)vl&OfU5PKL?_C+JxMUi=lAWb3EvR@!qda3Y zKW~esy>+&tJe9S@(ADqj+xStXbxyl4nh-;#@#)%G`|rbuD!dkXLGut?*2=$i5Lm%u zv;G;BJ~PK&K3v_lv-a;?j8I5HY0R{Q5xzZ>F^ze7yBj{h=eX4f%+QJ{78dpl0@8+t zn1}>ibQAVR70aCp-8{H`E*P9X&59}j+k*$6+@|VUdwD#s8qqAzzW|m$jq;4jn}j(* z-8i@P8_FD+Sd|{Qo4M{Mg3n5|M{`~Yze%F4yT-e7JHN6;I@H;Fa6jSiAFI_txQ&#@ zZ7HhUNG6M7L6?u=@PjxtF}H*6rcz!lRHBQD}LIXOD#VY z8P&0TL3Xpiow!ePD8b2k>Jf{`)z>zn&QXfAW7~AY;UR77?-EJYx6t z@pIXzxPRMVR?fOdIlmJ7lomyGT35q(!XTn>@~P~ADFveuJgrm=m2b<#8+ZN#jr&#d z;K81UTFImBxz57J=*VxkaPE$dj+)OIuLQ-xRma|OUs)1bMzT-NTaCds1Ab~@$#oya zz$GsYk3L=RFY||_Z*6u21&*1l`9!NS%$mv9QDd}BaP}q=&wv*HF>}W3< z8p_3u$T@)(0e(K&G3cRGlRs6aG>*-WMhZgbwV_f7{~*7A$NA@PBxB;@mUwgz+`r{L z14(CiF@Mb8OZ&kCKI8L@(O)rPIndV<|G_8d6Py%@|JQC$L8h<&xb8{*!Q#7@e0-QG&Sa}=l#F_Mm)UP#y0}qKJ^CMa z|MZ%SPrEH|@aJnW=ZKmb_tCs)H|h z@|m_gIVpV^yE}Pjw;oPWKlsbB|1pX$Y>)(OSZtCv_e~IOoGto-ESigh(#4{tTA!0 zF@6vH_oVY(4A%;n#MNe!>91JI=7)Ak<-ZE=lC6bno$?6X-M8+utw6xOTK7xu3BSWhJ^OBYyg8o_)ke9D3gMB)q*D8^C-jtsG65Gh5V+w4= zf1ZjN1r2SK6>TG&?;t`{d?{a`vRnPM-1cZw}b!%=e>` zS_2zmMkC~x1**xTC&M0@{N4*Ba+SdF?)pT5VoO z5(UT}J=?nS@ z&HVOPoRU8-jF=K{zDl>*_U)~I=w#_O$JSJ!vMa^p7uUP#H)f+Zk0_;}k4YkeU3;)EDBv4OFmI?;zo{;FFh@f$02V7A-+wyT)nVwn0( z0*fzycvvyP?7nx*%c$)yx;m#OsoH1GX{02DM&o(x$Aw}%c4v)S;{};D4VBf(MJr~z zeuAerPMm&daD!_Y3JTK7@>(Gl5CD7^637J@He@?klyLBN3mi z2y54w0|Zy2W72hdexUzt?K|S@{j!6Py!8i;V(3BZRq;zt!>{X}|6k0Vby!sU_wPL_ zpdupOprUk0N{6I$cL+$AbPfopNK1Ejg98jTAW};A&>=m;(8B;j+^y%FZ{6Q>@AKSv z?&UvV&z?QA*Z#zMz1Mm_?a3kk%oUQADq_<1BYq}ym>A`w6;oPK_6Q(jG0J<%x$w%2RVGC*Uj_#DwmmwH63 z!x^H*rQkN5IbF&{Voqnv?LO1VgTbH^dySuAFMS(a1sxX~q@ob8x)?vBw$NWojAFKn zD>BhI)|Q)`CR5X;rZQB->0Z0l{kl~@V7+qdS!@+2)f1ZN0E>wN6Fw3UTeiubu!1Ua zU%9sZ3+AU!rTJKsry_=RYw7%U{c}DBavK6cp_Qbxoq*_Y5DHw$!wU!cVQXlZn(|d% zM=}iQm3;oJhMWeQ%Bg0krxM1LYOl^JWDS^_nVC^ywnZv0FFDW7&dRJ3xzATNQE8_u z>9FDFPA9Y?We4LM_DdZWeV;k%g<3UKv5wYtJfc;(6`Mq%j=sF?R+sV+h)XAHA1_*1jj#M?MFPG=Up1H{_D0uio*hx` zvFL9MKq4z|cXx#V3&;1sD3L6aEJp8fFh-IX2)~QD#h;t9PrSTYY)gmlN*B>bG6Nup z>cc`sU^&5{L&BtqDI$pYc=o|kc3h@mH-_DA_Cy)P<&zRl!m%Yrn@TZiEe#}K*7Ehi zBlEjRWMNF`@t4}NH>16PNi~s|T86MsV>l_tYw@bLXTB_kWhJ1cOuv&-*?dp_n{Bg= zRZvjZ`)&TZ%kzcJ-G^jz_03(DeQriolZCJ5ULfP&srI%yUxUhXwg|@)HXBXKBPdF@ z_L6x`Bu6vGcYGoyowo##i#1)M!6W`OMv|jlQbYt%Fh9K7T=cN^(a#HAEg%z_FR-*V z@eMTpjAN-2cs$;X52StK!s@;i8dE;!mk-3)EzuJjrxvT%5l$f!&95AVwPCmAj?PQ1 zTY6>#w;J(2tcq>rDp8EVGw~Ui_Qx#i?M+nuPUsq3c5CJ68YEpN)|w6#;pKqfz;61z zz5%+I-^1zbW!Djg*%Y4mkxy%Vn=p-Q&kqFgk7|a1eu&}Iak)eX2TSpy<3GVnUCglQ z>$U~;KG1^nhQ&39u)k^B^V^-H^Jb?{MJxVT} zvya+Aq>gHCz-Pi$w+oxK9o2ZN-Ii>Yq!|J38oM9$K-p1%cf4shqa}x58L?G;jT}vm z2$+}-T1a@}!(G3_FW-G{bKFh4-)=hwaoq2m?x=;5RvS1|B0*vTn))8Vi`kwLdESp3b(av{;0A5Zdu0b z`Mm5!Z(U}vwPq5dqTO{kodOejAu*mDzLDpEezstk z<%UP>x*k48Hc)*Wv!QvFY%=l1Gc%!!G3;=ny7-oO)fX=CY(b3UQ=^6QYz0(+qF`S# z`0<2v7kr#y`*_N6_PIkrnKE_$n^f8nbmg3!AKl^M*iVM)GWk$gLa~!0XgnuObk8*e zXL`S}W;cRm;-}@hGxdS*bB2FD96cTMy_3)-Jt}w_Ux`!TJI43=M{Szf9TrI9F>X~- z?LoVB+8qb_9F(j7IzDh;gfIaVus2yeYeLFS^4?9pBCjb z?ky8gvH2a-usH8M^;CXI^liX&b8&ZemWG%-U53jH$=7GJE)PO68A5DEb{HB~|PaRi}P}6vhj8f$Ag+-wkJY5`Y@y1_^kUO>w4M z$URCsx^y}b0G_(50~{9PFQ1_JJk8@u-6q$a)P;&fkPpemk}gw>bhGcMwf~+hx=D%g zc-GG@U(8JpYDJbv*19a%?V^YcQREfae5@s|O+S50jZH!OD#F5rv(Izc+1Ny8bI7Tw z^B}@}kUi_*JpHmC7cHy8o!XV(;>(OHE1-@ust7)xDaa^xH!9FcS5576UlQAnn3coj z5-lG2L8?`h-*6iTmuADS?EN>aLnh1cPuuMqC^%5>fSUU1!lM4P-2_xyL<2at=skw2 zmFI!zz>90$ipj|DvP8t?V#XRMdz~7H2OJgKC#o-vvP;m-ZvahHTeGeME^CWZ2Ma5{ zr7GIO1}@86+b@Rb*_4!pj>my1?L=O!LeMWQAfOx)JY=edDqXFw0Xk&lG*)=v7z=qC zO)^B+Z?;}_#<<%d_@Oly!c=5~LXiw(XSbh@x2?0H&~>S0S2NN`<5n%axORPkK5ikango`qZQip%1Lk!pd=|MT?!IGbvr70?5=#_i*)bNqNpjEo zoD}be#55byYP|f;bA^A9PXd@^t)^zn&oun-_Lj!0u*ivczU`kC(eT+yi)*tPj)6qVQi6X5Ny#D8d>HI} z7!RhF7pv!T66yU&g$ajAh#}QL)Y3m6*zGoi?2X0XicO+fb;~=`_vY;1b~EVia|cyu z9egrPsx>a}uM9SKfY}wXrt>`M)&}Q$^^m-~pR8D#fd_y*4aKJ1Ot2v%k{fK2zMPMhF_s$b8k@(r)ci1}TB5iY3tK{9 z6G`(FC>~WGJlahn) zr+MsJa9A$xm4hPMA({z@08FB&=lX;#yWPY*4Y6{9(YW(xnoH|9oTfV6&F*SrT0r@= zQ?NO(azSbBeJU*WV(2A0qo01qu-)3)0Z@rq*_#R6^C_r5(UBo1)KkrRC7A+}b+7{Qs@R{zcZ2ygCElmSR>~1m9xl*6b z*^>Z2JW5CBrP^I#v_TFi)V~5NLU;a{TzcbPIF)XI&v+5{XxR3j@s&m-IV#;$M1!%F zZXeo(1$6cdj}`XoIF98r$XA3rat^=y`jEmuimvAo4(y_t=6_)NqzxUIaYy}4-zrV*$&jVr&+>yM4(9L z%AK*JBIB%1sh1v3!?ZMdXYrmxhOcAN=jeGPxfDwYSx`=0vndR(tG0P0*2Sqyj?bL$ z%O4)1Y7s)ThS#A1KE*Njcx?;Xt%pXz@;8Rsi#=XN1p4`ae?>bj$b{QI%n|TJ)0*SYeCy3l{SVzp- z;cI=L#VVml9oRV%UA`j}Y!xL7PVFxSlWz4pw?POdP=ZTa=vaP-*)p?B->t7J7Vw!D z=1<>DA5%-Q;N1d% z+k~S|BG!H-*M?qZJ3_lfaPs_w+AFKs=cC&Z^n9 zxvq2~-_Zg{lXVRlMA+hW0|Wf5_|+GJD)+G7HRO8b62B98^m%g*IT6v#K|xrBh5_e9 zSpl$Eovo)Wj`oI`KvGQ>Feu%6M0N-qJ*qWXa*==+o*h`PwN!mRs519pU$x9cieWZ3 zc|*hkfQn#BuzFNjbfzI+9p>JB3QW^N$}`hlYP$uuPjH<;gZgY{Yn+9Y%xcm4k9ici zHo@k4@}G3v;Z`qrT(G*^i~Ng_;Pwe3nmLyB5WzajF|7|KO?Jh6Ek847M%Fv}NKnIG ziXN}KW6HRWG&9eZR4S?VgN#i_E36wWu3lp8iZ4G1?-y>Zs>yjof|ycIj>#HnmZWQj zu}8AmskQoURJ1uxPgIraZLJpxK`oL+g}z-$!?%rgUyrx=yJi83htn`gN|K5&PwwV)zJ=Q$?O^7hsRqF zfb9v=K3k?NWMkSFMlS^iwbr7Y+nnW=C}P%p)#w7JfgEh)=?ABIxB=c#|G;e_qK^AK z_eLVId7V5eH>yEaUAd`A4#dpIB|w4}a7aW64!TK&fqNpN>Z>w<`jr?LiR}syU$X*1bqDzesh8^TZ*ow(52TO*hj8TP z3)-PnWoUD&Im|ecgDGup&^Hdq9*p~S7Hv(VIv6b#M5;I{O=_}XM!mh+L*Nii*EfT2 zOzCymGzaqOiSfX-KfdT77l3J@XGxs0^0HrNdTx+`RkdxQvq#8cQ}?Leah8p0-~Z-K ztvWYhQLXwf&_TZ7(dXk~gH9t=Io_^EI+y(3DODLFmd+5(tQJp=F{WD_dDt?mT|MIU zF2@2`v_cNoj{IR%s4`vnJjWq85~kbSk*I-g;H&w-(7e37T!8@Gvw+x2QDuuCl% zRhYjniO-@=1|}AOU*im$Bn4BDG#pYrr&j0AEie_~6`^K29zYqkFPQ}IwEwR4lI^oo`vaSwt zhLY}mU$ZS%$)1cB#b_NL+~nF#%lM@qxDGF_;(MI3(1cGbW(PCm)%=)0SFx@F zK#89Z{ox73q*($gCnl@NXG{0*f~T%_rHT`ojd>9|Pdb1N$zOO709(+vl4iQ}Q_1hG zeJ~i-S7q19Udl_3+bkjidiSi) z!TGJNYwHMBIv(!P@&R+kzqJ5!bD&cdg8!TN@c%Kr{Kwlf4r8-;O}PVD+4VP&yKzI} z8!j$BY?0nNfy+?=&Gqybavj1kQSYhyv7SBA3%dXNX5P5F*H`~KQ!G%wRO5DI%;OaR z#mQ>PEnfKQ70q9V1XHBi z$TD#Y{X)I?cX08+n%VJyD9kbZ#u-Na$Da_K@Eagd5DMHbd-<|6K}hBXM$h|(}MSv6nAEfj9J>U7ISI0o&a874}jP~#U#e#3V6Zo4d-}uDyfAEjK^4^Wa z#DsZ@$<@qHc?YDjNq%3%^5RB(mm%}biv-@%VJIlqiy$};=lzG!ZU1F$KpK+A%X~qy zyom@JzW3+%wAbRwuD8iyqWxCBM5A~=IXM{(4II7Z0$!DY-&&FI<06rvupLK?>i6Ma zFE;^O^k!FfdFFVW`9QKg0D*^MVSQkF%Bz(+UhMvl8!7Ax559;$@9Sj@!;lmHV&$7R zIa%GB6Fi%LjTOL8hTEAz4NYkZiy&A!6JYhzVLU;;PyKw+?SFpr%aJuTZ;`^DIuv6C zZ~qhu{1e}|;AiR-xwDCGKK=iO%Ksy-|JOtT5>E(8i$3E9-NS#7^sSRq}3i3zYL zNmMOMh=X_q8v|kYFOmfLr{6kB{LdqmwBG+U;G}*{dH8QC2VjKreXM4tTg?z15AF2^ z#;q`l(v%zjdCad-u!nc^`aLAspXZJIXI15Y(>4%>E)y{l^@c6x`}0@=LP$VG@h?h; zuv?M`?J|F2UV5hgIp{wJq^k9Aw-qjH&z#r4r|EZM(C$k~Bcz2WdIHL>3! zkcW~7fY9VOJ_gR~vP?*(cD}0ry;gMBYaJ)TMV)tQecaCuOn3J^Zy5=q*j0U3eR}c<2`LfF! zHteZ$2a1#U@+aYKr5DCd*Yqm-B~~RMH3TW0c(CVGVBM?H7SA5 z4ZBb^{XAO54^wrikj_xZZLPE^<|(|}KoD;B__0u{Z>5H4igBsix@&fEvC(v(1SW|c zmLFxJHkNP~Y^LDw*PM8}JLSX2kK3sq9e>TW=QYkR&<9;>$Uo=-o$%wqR9?2J@o1z0 zP^O=ISZ_Ktpei;IF9X1MuY13xEpLaeqdW?#LG+bLme1Jre(s`!^2U@ONPn6r`7ZW6Cr zq1w%-77B4)bD+6QA-}6&2IXsoex1tEwp*czd$C5oo2V%k&BGr2<5$^%g)}W_Kn-4e z{G*Pu9<`TQrnUP@`C?1d&(GVtu}!Z=3%m&`b`(Bgas$>4O`}J43l$wGzs41S_D)o7 z-eAw-G4Y`r+7=V@JY>6U-NnZ=+;#Y$N(2_857UoOgzVS0m4};ymD{HN`R=xw<=W%t zc4Ik7m;jY*!D6)dxio9wnQ}nDEA1vDX6QnFqDG<8NmB(r0@G-(U{dl-7Q1-Y<}DPUhKJ8Lhb6IH%Pd_t&SoKiUHYsWw<(&LXLGLi5-kpyaRb%67}q;! z41?%^r{XJg7lumba~(5U|B+~n6O%C#SqsXCu6?XVO|@jEm%r^0IaxAWS@CpVL55Qk z54yQ_veKg6UdTsEay_!Z7Ul72^^a!+?s<0;|7PhUkiWR3v1Kq=j+YR79*d+`1MYvbJX0y?T3PY)7iJkwm(hDqk?uTbp^LO$?Fv2>^eFqn+AR6}a%;@5*BeR+r5Lv!W4 zDmUz#T>6)UyiZF&JA)h4RFEf{yd_lUAfdJs|qGv?_r;-fBQqhZw`68JHB`rUJ8#w zHdzj*>2pq&=N^&WM8b-%+_X`beUtG04UBKTCN2rbamfQ3u_7tCm?a*B=`XpUOYJ_! z^jN-X=U%I~zD02WiKCjsVpBVZX%mAf9bKZ|mzvpj4b=rqlzP1Hq}Fk*zb|x;NmQMw zTBmZM;PdB)lNCk=oMNWk0TpLb*ZZng_PtpleR0g0uA(V}ZB@@DUEmoq3UEYu5h{DM zivpK~kg$x#?*suTb67Nga@E@z8UBbrV`XDgvJlTK2E4zXiHn;~R%pnIU$mMKiMMhw zJ?8kY(R@R-^kobu{c_fP$K4(2Rd1cv8;(icETcKqV1F?PVOh@N8 zx58&%zZ&5qbUiF)$)T?#GuzM*G&X%S=rzk1nESHISTm@Ib32B>VxFG3Izud9Ilp)N zv`5@=tzUbUg2d(`ueD0Nje}LlLpLqdZF@EqEYxNT&dL@y3<{0es{S$VZhk~s*q^M6 zl?Htyr|5l$c4$Ej?WIae=-zwCubY-)* zx^=!(?=vr*=nrlgTOTeSTxCRTIU}@vcJBCPiMJnA#aRyd_fISQWBj(E7I8HK?tOjx z9RNR8ZDuoXwjotgzLPF*k|C^Us=o7EY6SvHB>-kWH>;9JoqZ9d$2;KT>$fBl=ej^= zI8ld8KQ}*oPQq$50utdS8V7jZXrw={J!#q;6bd~)_-tY~yUPyvHrx@?{f*{nVy%Jx zi>iP!yne66ZCGs5W?~3igeQE6oXe~^D!e@a-4yjk)&T}5OlBU2H0$ta)B;H-0|AQ) z;L(`|i3n{_4>s*yAcZk0Rr=S(Xj-5RN?#=B`f z#KUsro!)y{ZA$iiX?Pe!4LlN!bg6sSf0i7-cEn998f$h69I`297m2?5S)FXb@ zv#jcF^hQ&_uxC{L^@t1Ay1RI%pv4w$q%>n zzP06&5m`BT}@B7W+$?Ro1VW3}`mk7L;N$yUFNxZTcICxuv% zR0B_{0`m_?+f8Mb^~b#)=j)I`{xaVuOdI}1;U}PWBTF2VW>Q?Y^S$sEGPbdz+EtW% zroo|Ne2;8PD+*Eawq;7F#Vt7rvR8%^PO3gNR(On#6g*T-m(>=h$d3fb|+N}ecKDE*ZXH4+C4Ex*~Yv3 zBKRH2`85Efs{Ta@e+x5(%!lIp_}IF{>uMKt+V^Qf8rp0}y0W~QI&L|58|k5CNbC;2 z9KLYc66^gti;{6Cyl2C)h4=NE^$8Lp#HV@CO9u^5yckSFXP)1W{$ya8#a5O!*>}zQ z_HQJAz47*V_)4m5DyJU1b>TxjX>as2szf#bjNFD!xsGyPiZ?Y-0@{i7XQ&|o@< zyHgjh_0d&q$ZAY`l`aB?StQ;tZ_!C^1nMu1?HwdXZ%ev~&Usx~$Wb(3G}&ccuG>Cw znlTvMkyj>i&1O-T9v0Q?0JT}2LuLf5Cch0Qh$3#C=(Xpr9-qxSuaXd(I?ox=7+m=& zKm^-2)7)#grZVKZzdS=SO?C+_{F)^iiE6QAI+c6^t_S%&c}Rc?NCzWGnxAO5jwp7y z-dmLDZi2pE-r2iKNB`i=oV6C5Q$06u`y9i3H0)}ZY4Py9e5>c;p5M=+pVzt z42wR4-z@XRnQhXxUDv6>^dvP>TE|HUU^bUhSK81Laic_bZg0_foVc5-MEp zabv(C!C5pNLC(ifgbJO3?cAiy_mpulr zFf;s?Gez5d+i&v)G=_&NKPx++rjU;3awlw_6S=HRPNta3Oxf2dH2Dyxr9wI~e|T0F zmEdwJ3Tu$5mtqyC{9kFjQ59bikZtv;(1Lu(VlmO5eF1k@mF1pL8J4| zutcs~*3WHG*uRvkEzv)#BgAya3fep?yV|70rkV@E36DVrm@BKYMtxQuF-filgn;*H zN}EaRH?g)8zgz>KD9Y0$j3*H5%K%Y1O+h;^>bV|-*BjLN*V716OaiHDKKFSg;b*hmUFvQe>f&B+)M0A8wY+Gl8Lix7 zBKVl!8VcRqaeMSWP@azv8Q2;wRwOw9L?fck4OJNerF63%I?)Ow?r+ywyZ~JpN&e9V z%4|yq2 zMZFNwQw`0n8F>yuZQns3Oh1`hXVpcWj(a#N@v3t%{8GolIC-fOq%-f)%mAv1??F`# zgI0aa@=PM_{t_37?4&VeHskI53(Bi?gh%>Dim zbWzIS&kqxgBm6ZuW(yDiDhK=7+HBcl#15?zH_%$wxY-}o5d4#BFR((+)k-(LXn1Z; zDZ!}DU%5{5pl{Ot9v-e{_?tg=EqZz?g&lc$>2Dc>;|8iGfn`MPrprYa1$3r1)!49h zuZG!|33JPCv?Qtdm^K#>rZ>vTr7;h|f`fPJ_ELiLC~9r0{C0A7`!cvPp@ZEO5CQ*h z4uiCS6l_2w0A;7OucvmbnArzKfLHE?D;hRvjuHRR4tU!I)U?04d4W6+7TyF^3gNc5 zZhPc;X_z`@J)cprsJfhFYsp&Kb_}$)OwM|NJfx;2EOP^TR)6iwu5(+w={G-5n|e1~ zUm;Pg)Qf(qhxZRR<|kLj7{d;x&&7}Xg+W?5ujnsD-J*e!PzD$Y z+nAY5L_D8NVFZqk>?)Ds^7@LRrEB^;ta9D11~a9h#`I(T{f3q}i>-k#-T%s-s9<$} zM=+)3uJV;9crKBeGJH$qP?wHb3m9=K8~tKWCwi&Hg@^E>f3V2%C3W2i;L7|Mol3cx zV-suu@f>t~Lj_2%DaVS_7bLps)nEMwUC&GXAJFyP*muC%G#Mb)EBTCCZ72PnX$L@M zzs+{IDQYF9^n@Q!feN#vZ}(XF)>6ct0+X=Acg$6P7?5~+K;@%+XCRp|d2x5lAico} zqCWMBn%H=JGHPoGts{9*?wmAZoIOZ;`39rdB5i_GS_Mp@LGSS6yRvBz5rJ(KjB&5VG{!%)fZ0z zvJhfrwfnetN5fSXcB~kxa!z3h!*xK-hHsvQVWI!?&<3TnvJqBYWSy06D-Tt(h+b3P z)tOW2gNjpiKC6H-p6j_%k6U6j2-Y;zeXjx2lCPII^O7z;p)e9s;sWypA^71ak->{C zDRlFPSQ$KEPRy8u;H3kqXk6&LR_y@blAsGSPO23n0G59|Ge{xdo)w&l^ya0fivpA&K#k7m zb2DH^dv@Z&r+l{ibCOoO+TWuBy_KegqW2hdBV1N;P}dDY2mTswHzT5Xa{i#j?s*hYi7bW>Y9S zu5DaE=g8F3FbFTvm@%KNM}I!Y%Qk=E^eR^5>&LIbXRb3j