From 2c2fd210736df9441907bcb467a792993a7220f7 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:40:31 -0700 Subject: [PATCH 1/2] docs: use american english spelling Convert British spellings to American English in hand-written code, comments, UI copy and docs (owner rule, 2026-09-18): behaviour, labelled, normalise, tokenise, analysed, centre, recognise, serialise and the rest, 216 words across 76 files. `sprout corpus diff` Markdown now says "Not analyzed."; test names and private helpers follow. Kept: the `analysed`/`not_analysed_reason` fields of `sprout corpus diff --json` (commented), GitHub's `cancelled` literal, verbatim EU AI Act terms, and the plant-care corpus plus eval data that quotes it. --- .github/verify-release-tag.sh | 2 +- CHANGELOG.md | 44 +++++++++---------- DEFINITION_OF_DONE.md | 2 +- Makefile | 2 +- README.md | 2 +- SECURITY.md | 2 +- docs/PUBLICATION-READINESS.md | 6 +-- docs/RESPONSIBLE-TECH-AUDITS.md | 2 +- docs/ROADMAP.md | 2 +- docs/THREAT-MODEL.md | 2 +- ...line-deterministic-generator-as-default.md | 2 +- .../0002-hybrid-bm25-plus-dense-retrieval.md | 2 +- ...-citation-guard-for-100pct-groundedness.md | 2 +- .../0004-never-certify-safe-output-guard.md | 2 +- ...9-judge-model-differs-from-answer-model.md | 4 +- ...to-plant-id-as-selector-not-fact-source.md | 2 +- ...atic-vector-semantic-embedding-provider.md | 2 +- ...verage-risk-curve-in-calibration-report.md | 4 +- docs/audits/gate-inventory.md | 2 +- docs/cards/model-card.md | 2 +- docs/plans/improvement-plan.md | 2 +- scripts/generate_static_vectors.py | 6 +-- src/sprout/confidence.py | 2 +- src/sprout/corpus_bundle.py | 2 +- src/sprout/corpus_diff.py | 12 ++--- src/sprout/corpus_registry.py | 2 +- src/sprout/determinism.py | 4 +- src/sprout/disagreement.py | 2 +- src/sprout/eval/stats.py | 8 ++-- src/sprout/eval/suites/language_parity.py | 12 ++--- src/sprout/eval/suites/safety.py | 4 +- src/sprout/guards.py | 4 +- src/sprout/identify.py | 6 +-- src/sprout/ingest.py | 2 +- src/sprout/lexical.py | 8 ++-- src/sprout/propose.py | 6 +-- src/sprout/providers/base.py | 6 +-- src/sprout/providers/bedrock.py | 2 +- src/sprout/providers/deterministic.py | 8 ++-- src/sprout/retrieve.py | 4 +- src/sprout/review.py | 2 +- src/sprout/site_meta.py | 2 +- src/sprout/store.py | 12 ++--- src/sprout/text.py | 16 +++---- src/sprout/web_bundle.py | 2 +- tests/test_analytics.py | 4 +- tests/test_ci_parity.py | 4 +- tests/test_cli.py | 2 +- tests/test_committed_artifacts_are_current.py | 24 +++++----- tests/test_conversation.py | 2 +- tests/test_corpus_bundle.py | 2 +- tests/test_corpus_diff.py | 16 +++---- tests/test_error_response_disclosure.py | 2 +- tests/test_eval_core.py | 10 ++--- tests/test_eval_history.py | 2 +- tests/test_eval_suites.py | 8 ++-- tests/test_install_instructions.py | 6 +-- tests/test_providers_and_extras.py | 22 +++++----- tests/test_rag.py | 16 +++---- tests/test_redaction_is_opt_in.py | 6 +-- tests/test_release_versions.py | 12 ++--- tests/test_reminders.py | 2 +- tests/test_retrieval_scale.py | 10 ++--- tests/test_safety_routing_by_topic.py | 6 +-- tests/test_server.py | 2 +- tests/test_site_meta.py | 2 +- tests/test_smoke.py | 4 +- tests/test_static_embedding.py | 2 +- tests/test_web_bundle.py | 4 +- tests/test_web_static_parity.py | 2 +- web-static/README.md | 4 +- web-static/src/config.ts | 2 +- web-static/src/generator.ts | 4 +- web-static/src/lexical.ts | 2 +- web-static/src/store.ts | 6 +-- web-static/src/text.ts | 4 +- web-static/test/bundle-pairing.test.ts | 2 +- 77 files changed, 212 insertions(+), 210 deletions(-) diff --git a/.github/verify-release-tag.sh b/.github/verify-release-tag.sh index 789d94e..bb93df3 100755 --- a/.github/verify-release-tag.sh +++ b/.github/verify-release-tag.sh @@ -19,7 +19,7 @@ # allowed-signers file. The file is read at the verified ref, so trusting # it is the same trust decision as running this script at all. # 5. The commit the tag selects is the commit being built (EXPECT_COMMIT). -# Without this the signature check is theatre: it would prove some tag was +# Without this the signature check is theater: it would prove some tag was # signed while the build ran from something else. # # Grandfathering. Tags cut before this control existed cannot be re-signed -- diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd9c4..1c30302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,14 +36,14 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release The prose now says that, at every site: redaction is an opt-in that enabling a cloud provider does not turn on, T5's residual is Low offline / **Medium** in cloud rather than - Low everywhere, and R3's control is labelled OPT-IN rather than AUTO, with its residual - carried by the offline default and the opt-in-and-labelled nature of cloud mode rather than + Low everywhere, and R3's control is labeled OPT-IN rather than AUTO, with its residual + carried by the offline default and the opt-in-and-labeled nature of cloud mode rather than by redaction. `docs/adr/0008`, the corpus data card, and the `docs/ADAPT.md` and `config/sprout.yaml` comments a deployer actually reads say the same. Three new `docs/claims.yaml` entries pin the sentences to `config:generation.redact_query_pii`, and `tests/test_redaction_is_opt_in.py` pins what the prose asserts: the three declaration sites - agree, no provider choice flips them, and `redact_pii` recognises exactly the three classes - the documents name. Behaviour is unchanged — defaulting the flag on for cloud providers is + agree, no provider choice flips them, and `redact_pii` recognizes exactly the three classes + the documents name. Behavior is unchanged — defaulting the flag on for cloud providers is the other repair, and it is the owner's call (#179). - **The required secret scan read 1 of `main`'s 146 commits.** The `security` job is one of @@ -198,7 +198,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release *file* to a non-empty list; nothing held the *run* to it. The tests **execute the shipped script**, extracted from the workflow rather than - retyped, because both defects were runtime behaviour and neither is visible in a file + retyped, because both defects were runtime behavior and neither is visible in a file check — and a test that compiles its own copy of the thing under test is a second, weaker gate. `skipped` is asserted to remain a pass in the same suite: a check that names the failing gate is otherwise satisfied by one that refuses everything. @@ -233,7 +233,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release The cross-language half is pinned rather than argued. `indexChunkIdsDigest` mirrors `web_bundle.index_chunk_ids_digest`, and the test asserts it equals **the string Python wrote into the committed bundle**, not another TypeScript digest — the only - comparison that can catch a canonicalisation disagreement. A digest the port computes + comparison that can catch a canonicalization disagreement. A digest the port computes differently would fire on every correct bundle, which is worse than no check because somebody deletes it. `indexChunkIds` refuses an index with no chunks or a chunk with no id *before* hashing, because an empty list hashes to a perfectly good digest. @@ -357,7 +357,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release `date-released` and `-dev` marker compared against `git tag --list` — needs no vocabulary and is unchanged. Claims are matched after stripping Markdown line markers and collapsing whitespace, because a wrapped sentence is invisible to a substring match: `CHANGELOG.md` - carries one such claim split across two `>`-quoted lines, and only normalising finds it. + carries one such claim split across two `>`-quoted lines, and only normalizing finds it. Nothing here is tagged, so the live scan can only return an empty result — and an empty result is also what a scan that stopped finding the tree returns. @@ -383,7 +383,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release bundle carries `confidence.well_supported_cutoff` and `prompts.confidence_band_labels` (`BUNDLE_FORMAT_VERSION` 2 → 3, and a version-2 bundle is refused rather than defaulted — an absent cutoff compares as `confidence >= undefined`, which is false for - every score, so every answered question would be labelled "partially supported" and + every score, so every answered question would be labeled "partially supported" and read as a calibration result). `derive_band_cutoff` re-derives that cut point whenever the confidence function is re-fit, which is precisely why a TypeScript copy of `0.70` would have been correct until the first `sprout fit-confidence` and silently wrong @@ -454,7 +454,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release Both defect classes at once: a gate that could not fail, and an absence rendered as a measurement. `tests/test_eval_core.py` pinned it in place with - `assert cohens_kappa([], []) == 1.0`, asserting the behaviour as intended. + `assert cohens_kappa([], []) == 1.0`, asserting the behavior as intended. The two degenerate cases are not the same thing, and conflating them is what did it. When every label agrees and expected agreement is therefore 1.0, perfect agreement was @@ -515,17 +515,17 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release computed, because they do not measure the same thing. Flips are still listed, since "it failed there and not here" survives a judge change even when the arithmetic does not. A new failure stays a regression in both cases — only the absence was ever ambiguous. -- **The embedders normalised with `pow`, and the browser port with `Math.sqrt`.** +- **The embedders normalized with `pow`, and the browser port with `Math.sqrt`.** `x ** 0.5` is libm's `pow`, which IEEE 754 does not require to be correctly rounded; `math.sqrt` is `sqrt`, which it does. The hashing, static-vector and Titan embedders each repeated `sum(v * v for v in vec) ** 0.5`, while `web-static/src/hashEmbedding.ts` - -- the port that runs sprout.chelseakr.com -- has always normalised with `Math.sqrt`. + -- the port that runs sprout.chelseakr.com -- has always normalized with `Math.sqrt`. The CLI and the browser were therefore using **different square roots** for a pipeline this repo claims agrees bit-for-bit, and two runs of the CLI on different platforms did not have to agree either. That already turned one build red on a file nobody had edited (`static_vectors.json`). - `providers/base.py` gains one `l2_normalize` and all three embedders normalise through + `providers/base.py` gains one `l2_normalize` and all three embedders normalize through it, so there is a single square root to be right about. Measured on macOS (arm64, CPython 3.12.14): `n ** 0.5 != math.sqrt(n)` for 274 of the integers 1..200000 -- the shape of the hashing embedder's norms -- and for 32 of 5000 random static-vector sums. @@ -571,7 +571,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release appears must carry the declared version, must retire those sentences and the `-dev` marker, must bring `date-released` back, and must have a `CHANGELOG.md` section behind it. `sprout.__version__` must equal `pyproject.toml` and must - not be the `0.0.0+unknown` not-installed sentinel, which is a labelled unknown + not be the `0.0.0+unknown` not-installed sentinel, which is a labeled unknown and never a version. `CITATION.cff`'s base version must equal the manifest's. The README's Status line now reads ``In build`` (0.1.0, untagged): the number @@ -766,7 +766,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release - **The published site's root page now has a link-preview card.** `web-static/public/index.html` declared the full OpenGraph and Twitter set except an image, on the explicit reasoning that "an og:image naming a file that is not there is worse than none" — sound, but it left every - shared sprout.chelseakr.com link unfurling as a blank grey box. `og.png` (1280x640) ships with + shared sprout.chelseakr.com link unfurling as a blank gray box. `og.png` (1280x640) ships with the static bundle, and the page names it with `twitter:card` raised to `summary_large_image`. The comment's reasoning is now enforced rather than trusted. `sprout site-check` fails a @@ -783,7 +783,7 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release cases would be answered and what fraction of *those* would be wrong. The `calibration` suite appends the curve to its existing segments. Report-only: the suite's PASS/FAIL is still ECE ≤ 0.15 plus abstention enforcement, unchanged. Rows read - `risk @ confidence≥T (coverage C)` — `T` is a confidence cutoff, so labelling them + `risk @ confidence≥T (coverage C)` — `T` is a confidence cutoff, so labeling them `coverage≥T` named the wrong axis and contradicted the numbers beside them (at `confidence≥0.25` the committed corpus covers 100% of calibration cases, not 25%), and left coverage itself unpublished. A point covering nothing reports `risk: None`, not @@ -807,11 +807,11 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release on any machine-made artifact not either gated or listed with a reason, so the list cannot go quietly short. - `scripts/generate_static_vectors.py` gained a `--check` mode that renders through the - same serialiser `main()` writes with and compares without touching the file. Editing + same serializer `main()` writes with and compares without touching the file. Editing `src/sprout/data/embeddings/clusters.yaml` and forgetting to regenerate previously shipped a table the code no longer produces, with every gate green. - **The static-vector table was not reproducible off the machine that wrote it.** - `scripts/generate_static_vectors.py` L2-normalised with `x ** 0.5`, which is libm's + `scripts/generate_static_vectors.py` L2-normalized with `x ** 0.5`, which is libm's `pow` — not required by IEEE 754 to be correctly rounded — rather than `math.sqrt`, which is. Measured 2026-09-01 on macOS (arm64, CPython 3.12.14): 91 of the 276 norms the generator computes gave a different double from `math.sqrt` of the same sum, and @@ -820,8 +820,8 @@ fixes. Security entries reference the advisory (GHSA) per the portfolio release gate above duly said "is current" locally and "is stale" in CI for a file nobody had edited. The generator now uses `math.sqrt` and the committed table is regenerated; every value moved by at most 2.8e-17, so no rendered score or report changed. A test - rebuilds the generator's un-normalised inputs from the real `clusters.yaml` and - compares its normalisation against a `math.sqrt` reference, so the divergence fails + rebuilds the generator's un-normalized inputs from the real `clusters.yaml` and + compares its normalization against a `math.sqrt` reference, so the divergence fails on whichever machine has the disagreeing `pow` rather than only in CI. - `src/sprout/data/embeddings/manifest.yaml` restates the table's `dim` by hand and names its source, its generator and its ADR. The dimension is now checked against @@ -1201,7 +1201,7 @@ the public evaluation harness as the headline artifact. - **Facet-coverage answer planner + a `completeness` eval metric** (EXP-01, `providers/deterministic.py`, `eval/suites/completeness.py`). The extractive generator now splits a multi-part question into per-clause "facets" (`text.extract_facets`) and selects - sentences greedily to maximise *marginal* facet coverage before raw relevance score, so a + sentences greedily to maximize *marginal* facet coverage before raw relevance score, so a two-part question ("how often should I water, and does that change in winter?") surfaces both clauses instead of three near-duplicate answers to the first one — a single-clause question is unaffected (verified byte-for-byte identical output). A new deterministic @@ -1234,7 +1234,7 @@ the public evaluation harness as the headline artifact. `providers/plantnet.py`). A photo is identified into candidate species, the best confident match is resolved to a species **already in the cited corpus**, and that species is routed back through the *unchanged* grounded pipeline — so every rendered claim is still cited and - toxicity still routes to a vet. The identification is labelled "a visual match, not a cited + toxicity still routes to a vet. The identification is labeled "a visual match, not a cited fact" and never enters the answer's sentences. Offline by default (no network, always falls back to "type the plant's name"); a `plantnet` provider calls the allowlisted Pl@ntNet API with its key from `PLANTNET_API_KEY` (env only). New `sprout identify` command and @@ -1396,7 +1396,7 @@ the public evaluation harness as the headline artifact. key-and-placeholder-parity diff. - **Retrieval scale architecture (FIX-07).** `BM25Index` is now an inverted-postings structure (`term -> {doc_index: term_freq}`, `lexical.py`) built **once per corpus** instead of being - retokenised on every query: `ingest.py` builds it over every chunk and `store.py` persists it in + retokenized on every query: `ingest.py` builds it over every chunk and `store.py` persists it in `index.json` (format version bumped to 2; a v1 file now fails to load with a message pointing at `sprout ingest`). `VectorStore.search` accepts a `candidate_ids` filter and selects with `heapq.nlargest` instead of a full sort, so a species-scoped query's dense scan and BM25 scoring diff --git a/DEFINITION_OF_DONE.md b/DEFINITION_OF_DONE.md index 128a47f..dc1cc71 100644 --- a/DEFINITION_OF_DONE.md +++ b/DEFINITION_OF_DONE.md @@ -77,7 +77,7 @@ here; per-repo target values live in [`docs/ROADMAP.md`](docs/ROADMAP.md). | Reliability | fault tolerance / recoverability — degrade to offline; rebuild from `make ingest` | | Security | confidentiality / integrity — no query persistence; content-hashed corpus | | Maintainability | modularity / testability — independent ingest·retrieve·generate·guard·eval | - | Performance efficiency | time behaviour — first-token + eval-run latency budgets | + | Performance efficiency | time behavior — first-token + eval-run latency budgets | | Compatibility | interoperability — JSON/SSE API; JUnit + SARIF reports | | Usability | accessibility — WCAG 2.2 AA gate; learnability — one question box | | Portability | adaptability — point at a different corpus via `config/sprout.yaml` | diff --git a/Makefile b/Makefile index 5915b9b..bf8950f 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,7 @@ demo: ingest ## Reproduce a short scripted session web-static-bundle: ingest ## Export index.json + config.json for the TS port (EXP-08) $(PY) python scripts/export_web_bundle.py --config $(CONFIG) # Re-derive the provenance the export just wrote. Here, after `ingest`, this is close to -# tautological and is honestly labelled as such: its job at this point is to catch an +# tautological and is honestly labeled as such: its job at this point is to catch an # export that did not land (a partial write, a stale destination). It has real teeth in # `site-check` below and in pages.yml, where it reads the tree that is actually # published rather than the one make just built. diff --git a/README.md b/README.md index 5833c3c..6f34e10 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ key and makes no cloud call. than guesses. - Works in **English and Spanish** with enforced parity. - **Identifies a plant from a photo**, then answers from the *same* cited corpus. The - visual match only *selects* the species (it's labelled "a visual match, not a cited + visual match only *selects* the species (it's labeled "a visual match, not a cited fact" and never rendered as one); the care answer still flows through the grounded, guarded pipeline. Offline by default, with a graceful "type the plant's name" fallback and an allowlisted Pl@ntNet provider behind a config switch — see diff --git a/SECURITY.md b/SECURITY.md index da1c2d8..1cfb55e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,7 +42,7 @@ If you want an encrypted channel, say so in a first low-detail email and we will | Stage | Target | |----------------------------|---------------------------------------------------------------------| -| Acknowledgement & triage | **≤ 72 hours** from receipt | +| Acknowledgment & triage | **≤ 72 hours** from receipt | | Severity assessment | CVSS-based, shared with you with the triage reply | | Fix or mitigation plan | communicated after triage, prioritized by severity | | Coordinated disclosure | by mutual agreement; default embargo up to 90 days | diff --git a/docs/PUBLICATION-READINESS.md b/docs/PUBLICATION-READINESS.md index dfe575a..98a140b 100644 --- a/docs/PUBLICATION-READINESS.md +++ b/docs/PUBLICATION-READINESS.md @@ -27,9 +27,9 @@ it records. They are described by role. | Where | What it named | How it reads now | |---|---|---| -| `docs/adr/0019-frozen-plugin-api-for-sprout-eval.md` | an unpublished sibling eval harness, cited as the motivating consumer of the plugin seam | "the author maintains other, unpublished eval-shaped projects that could consume the same runner" — the argument for freezing the API is unchanged; only the example is generalised | +| `docs/adr/0019-frozen-plugin-api-for-sprout-eval.md` | an unpublished sibling eval harness, cited as the motivating consumer of the plugin seam | "the author maintains other, unpublished eval-shaped projects that could consume the same runner" — the argument for freezing the API is unchanged; only the example is generalized | | `src/sprout/_vendor/genai_telemetry/README.md` | three unpublished repositories, listed as the shim's binding scope | "every AI-full repo in the author's portfolio — this one included" — the scope rule is identical, the roster is not enumerated | -| `.github/workflows/ci.yml` | an unpublished repository, cited as precedent for disabling gitleaks PR comments | the sentence was first generalised to "the same stance the author's other repos take"; since 2026-09-13 the secret scan is a pinned gitleaks binary rather than `gitleaks/gitleaks-action`, so there is no PR-comment setting left to justify and the comment is gone | +| `.github/workflows/ci.yml` | an unpublished repository, cited as precedent for disabling gitleaks PR comments | the sentence was first generalized to "the same stance the author's other repos take"; since 2026-09-13 the secret scan is a pinned gitleaks binary rather than `gitleaks/gitleaks-action`, so there is no PR-comment setting left to justify and the comment is gone | | `.github/workflows/release.yml` | an unpublished repository hosting the shared `release-authorize` reusable workflow | repointed at the publicly readable copy in the org profile repository | | one commit message | an unpublished repository the CodeQL gate was ported from | unchanged in the tree; **only a history rewrite can reach a commit message** — see below | @@ -43,7 +43,7 @@ reader cannot resolve. That is not only a naming problem: **a caller can only reuse a workflow it can read**, so on the day this repository went public its release pipeline would have started failing at the first job. It now calls the publicly readable copy, which was verified byte-identical to the copy it -replaced — same behaviour, same trust boundary, SHA-pinned. This is the same +replaced — same behavior, same trust boundary, SHA-pinned. This is the same pin two already-public sibling repositories use. ### One exposure is already live and predates this audit diff --git a/docs/RESPONSIBLE-TECH-AUDITS.md b/docs/RESPONSIBLE-TECH-AUDITS.md index 708bf75..4cc51dc 100644 --- a/docs/RESPONSIBLE-TECH-AUDITS.md +++ b/docs/RESPONSIBLE-TECH-AUDITS.md @@ -64,7 +64,7 @@ value-chain are recorded as not-applicable with one-line reasons in the risk reg - **Not** a vision model that treats a photo as a source of fact. Sprout *can* accept a photo to *select* a candidate species — offline by default (no network, always falls back to "type the plant's name"), with an allowlisted Pl@ntNet provider behind a config - switch — but a visual match is labelled *"a visual match, not a cited fact,"* never + switch — but a visual match is labeled *"a visual match, not a cited fact,"* never enters `Answer.sentences`, and is never citation-checked. The care/toxicity answer still flows through the grounded, cited, never-certify-safe pipeline, so the photo path adds no new way for an unsupported claim to render. See diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4201d45..f26c3d8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -67,7 +67,7 @@ the statistical gate is on (see `runner.py::_apply_statistical_gate`). | **conversation** (a follow-up resolves species/topic from bounded history, and history is never a source) | **≥ 0.95** | `src/sprout/eval/suites/conversation.py`, replayed through the server's own `SessionMemory`; a history-injection case that changes which chunks ground the answer is an outright failure | AUTO | | **toxicity-coverage** (every ASPCA-listed pet-toxic plant the corpus covers carries a routed `## Toxicity` section) | **≥ 0.99** | `src/sprout/eval/suites/toxicity_coverage.py` — corpus-level, so no lucky generation can satisfy it | AUTO | | Abstention enforced below threshold | answered cases below 0.25 confidence must have been refusals (ADR-0012, supersedes ADR-0005; corrected 2026-07-05 — see execution log) | `eval/suites/calibration` invariant | AUTO | -| EN/ES pass-rate parity (aggregate delta between languages) | **\|EN − ES\| ≤ 5 pp (0.05)** — the gap between the language slices' pass rates over the recorded per-case correctness label, with the EN anchors scored as a slice of their own | `src/sprout/eval/suites/language_parity.py` (the `language-parity` suite, metric `en-es-pass-rate-gap`) | AUTO (wired 2026-09-05, closing the 2026-08-29 correction below). Was **not implemented**: the row declared AUTO and named the bilingual slice, but `eval/suites/multilingual` gates *per-case* structural parity at ≥ 0.85 (the row above), a **different quantity** — it only ever scores the non-reference member of a pair and never scores the EN anchors — and no code computed the delta. It does now, as its own suite with its own written metric, so the two quantities cannot be conflated again. **Measured on the committed run: 0.011 (1.1 pp) — EN 0.826 (n=121) vs ES 0.838 (n=37) — inside the target. Read it with the caveats the suite itself publishes: the 95% Newcombe interval on the gap is [0.000, 0.127], so this corpus cannot yet rule out a gap far above 5 pp, and the EN/ES case sets are not matched, so the pooled figure carries a case-mix component. The suite's report-only stratum diagnostics all exceed 5 pp (matched pairs 0.083, behaviour=answer 0.068, behaviour=refuse-and-redirect 0.052). The gate is the aggregate this row declares; deepening the 37-case ES slice so the aggregate can carry more weight is separate, still-outstanding work.** | +| EN/ES pass-rate parity (aggregate delta between languages) | **\|EN − ES\| ≤ 5 pp (0.05)** — the gap between the language slices' pass rates over the recorded per-case correctness label, with the EN anchors scored as a slice of their own | `src/sprout/eval/suites/language_parity.py` (the `language-parity` suite, metric `en-es-pass-rate-gap`) | AUTO (wired 2026-09-05, closing the 2026-08-29 correction below). Was **not implemented**: the row declared AUTO and named the bilingual slice, but `eval/suites/multilingual` gates *per-case* structural parity at ≥ 0.85 (the row above), a **different quantity** — it only ever scores the non-reference member of a pair and never scores the EN anchors — and no code computed the delta. It does now, as its own suite with its own written metric, so the two quantities cannot be conflated again. **Measured on the committed run: 0.011 (1.1 pp) — EN 0.826 (n=121) vs ES 0.838 (n=37) — inside the target. Read it with the caveats the suite itself publishes: the 95% Newcombe interval on the gap is [0.000, 0.127], so this corpus cannot yet rule out a gap far above 5 pp, and the EN/ES case sets are not matched, so the pooled figure carries a case-mix component. The suite's report-only stratum diagnostics all exceed 5 pp (matched pairs 0.083, behavior=answer 0.068, behavior=refuse-and-redirect 0.052). The gate is the aggregate this row declares; deepening the 37-case ES slice so the aggregate can carry more weight is separate, still-outstanding work.** | | Hallucination rate | 0% by construction (extractive + citation guard) | `tests/test_rag.py` citation-guard tests + `eval/suites/groundedness` | AUTO | | Judge ↔ human agreement (deterministic judge, CI floor) | ≥ 0.80 raw · Cohen’s κ ≥ 0.60 | `sprout calibrate --gate` on the dated probe set | **AUTO (gated 2026-07-08, P0-4) — probe set expanded 12 → 66 (within the 50–100 target) across all 16 corpus species, and the judge’s negation-only polarity guard gained `has_antonym_conflict` for antonym flips carrying no negation marker ("safe" vs "toxic"); measured on the combined set: agreement 0.955, κ 0.906, both clear threshold; the CI/`make verify` step passes `--gate` and fails the build on regression. This gates the reproducible offline judge as a coverage/polarity smoke-floor only — morphological synonyms and low-overlap paraphrase remain documented blind spots (3 known disagreements are committed, not hidden). See `docs/audits/judge-calibration.md`.** | | Judge ↔ human agreement (LLM judge, production gate) | ≥ 0.80 raw · Cohen’s κ ≥ 0.60 | `sprout calibrate --judge llm` | **not yet done — the LLM judge needs a live Anthropic credential and is deliberately never invoked in CI (see `eval/llm_judge.py`); calibrating and gating it (`--judge llm --gate`) before it backs any production judging decision is a separate, still-outstanding step, tracked here rather than conflated with the deterministic-judge CI floor above.** | diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index c4ce70d..7cb50c2 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -250,7 +250,7 @@ garbage; or pathological/oversized input or API flooding exhausts resources. - **Availability shape.** Serverless scales to zero with a budget alarm, or ships as a static offline build with no always-on dependency (`CI-CD` / `infra/`); rate limits guard the API. -- **Eval analogue.** A suite that throws becomes a `fail_closed` FAIL rather than aborting the +- **Eval analog.** A suite that throws becomes a `fail_closed` FAIL rather than aborting the run ([`runner.py`](https://github.com/ChelseaKR/sprout/blob/main/src/sprout/eval/runner.py)) — the harness itself degrades safely. **Residual risk.** Sustained API flooding of a deployed endpoint is bounded but not eliminated; diff --git a/docs/adr/0001-offline-deterministic-generator-as-default.md b/docs/adr/0001-offline-deterministic-generator-as-default.md index 5159f3c..36753bc 100644 --- a/docs/adr/0001-offline-deterministic-generator-as-default.md +++ b/docs/adr/0001-offline-deterministic-generator-as-default.md @@ -30,7 +30,7 @@ no hosted dependency. The **default and only-required** generation stack is offline and deterministic: - `HashingEmbedding` — a signed SHA-256 token-hashing bag-of-tokens projection, - L2-normalised (`providers/deterministic.py`). SHA-256 is used purely as a stable + L2-normalized (`providers/deterministic.py`). SHA-256 is used purely as a stable token→dimension map, a non-cryptographic use that also keeps SAST quiet. - pure-Python **BM25** (`lexical.py`) fused with the dense path (see ADR-0002). - `ExtractiveGenerator` — returns only sentences copied verbatim from retrieved chunks, diff --git a/docs/adr/0002-hybrid-bm25-plus-dense-retrieval.md b/docs/adr/0002-hybrid-bm25-plus-dense-retrieval.md index 34a1552..544fa3f 100644 --- a/docs/adr/0002-hybrid-bm25-plus-dense-retrieval.md +++ b/docs/adr/0002-hybrid-bm25-plus-dense-retrieval.md @@ -50,7 +50,7 @@ delta, per the spec. - **Positive.** Recall is materially more robust than pure-dense over the offline embedder; the lexical path catches exact-term questions, the dense path catches loose paraphrase, - and RRF needs no score normalisation between the two. + and RRF needs no score normalization between the two. - **Positive.** The species filter is a structural defense against cross-species grounding, which is a *safety*-relevant error (toxicity answers must cite the right plant). - **Positive.** `min_score` remains the single answer-vs-refuse knob, retrievable and diff --git a/docs/adr/0003-extractive-generation-and-citation-guard-for-100pct-groundedness.md b/docs/adr/0003-extractive-generation-and-citation-guard-for-100pct-groundedness.md index 151c40d..bd48b12 100644 --- a/docs/adr/0003-extractive-generation-and-citation-guard-for-100pct-groundedness.md +++ b/docs/adr/0003-extractive-generation-and-citation-guard-for-100pct-groundedness.md @@ -52,7 +52,7 @@ survives; injection detection in `guards.py` is observability only). which provider is configured. - **Positive.** Injection defense is a free side effect of the entailment requirement. - **Negative — the honest limit.** Extractive prose reads stiffly: answers are stitched - source sentences, not a synthesised paragraph. We accept reduced fluency for guaranteed + source sentences, not a synthesized paragraph. We accept reduced fluency for guaranteed groundedness; the model card states this. - **Negative.** The coverage-overlap check is lexical, so a *correct* paraphrase from the cloud generator can be dropped as "unsupported." This is a deliberate false-negative bias diff --git a/docs/adr/0004-never-certify-safe-output-guard.md b/docs/adr/0004-never-certify-safe-output-guard.md index 0b1e210..2e7ce14 100644 --- a/docs/adr/0004-never-certify-safe-output-guard.md +++ b/docs/adr/0004-never-certify-safe-output-guard.md @@ -31,7 +31,7 @@ A dedicated **safety guard** layer, separate from the citation guard, enforces t 2. **Deny-list output filter.** `safety_filter()` / `asserts_safety()` drop *any* rendered sentence — even a grounded one — that contains a forbidden certification phrase, in either language: EN `"is safe"`, `"non-toxic"`, `"harmless"`, …; ES `"es seguro"`, - `"no es tóxica"`, `"inofensiva"`, … The deny-list is per-language config, normalised + `"no es tóxica"`, `"inofensiva"`, … The deny-list is per-language config, normalized before matching, and runs *after* the citation guard so it can veto an otherwise-valid sentence. 3. **Routing.** Every safety query — whether it answers or refuses — carries a diff --git a/docs/adr/0009-judge-model-differs-from-answer-model.md b/docs/adr/0009-judge-model-differs-from-answer-model.md index 0674897..9dfb470 100644 --- a/docs/adr/0009-judge-model-differs-from-answer-model.md +++ b/docs/adr/0009-judge-model-differs-from-answer-model.md @@ -12,7 +12,7 @@ properties no string match can settle — groundedness (does the claim follow fr sources?) and helpfulness. The integrity of that judgment is the integrity of the headline artifact. The well-documented failure mode is **self-preference / self-evaluation bias**: a model grading its own outputs systematically scores them higher, because the same training -distribution that produced the answer also recognises and favours its own style. A judge +distribution that produced the answer also recognizes and favors its own style. A judge that *is* the answer model launders the assistant's own blind spots into a passing grade. `AI-EVALUATION-STANDARD` standardizes on Anthropic Claude for both generation and @@ -48,7 +48,7 @@ pinned in versioned config (`eval/llm_judge.py`, `eval/judge.py`). - **Positive.** Self-preference bias is structurally excluded: the grader is never the gradee. Groundedness/helpfulness scores are independent of the answer model's own taste. - **Positive.** The default offline path uses the `DeterministicJudge`, so the entire eval — - including the judged suites' deterministic analogue — runs reproducibly with no network or + including the judged suites' deterministic analog — runs reproducibly with no network or key; the LLM judge is an opt-in upgrade, not a CI dependency. - **Positive.** Folding the judge config hash into the run fingerprint means a judge change can never silently move scores — it is visible in the run identity and invalidates stale diff --git a/docs/adr/0010-photo-plant-id-as-selector-not-fact-source.md b/docs/adr/0010-photo-plant-id-as-selector-not-fact-source.md index f7ed009..6513ca9 100644 --- a/docs/adr/0010-photo-plant-id-as-selector-not-fact-source.md +++ b/docs/adr/0010-photo-plant-id-as-selector-not-fact-source.md @@ -22,7 +22,7 @@ contract at its weakest seam. A photo identification is treated as a **selector, never a fact** — the same contract the README already defines for Family-Greenhouse household data ("household data only selects and -personalises; only the cited corpus is a source of fact"). +personalizes; only the cited corpus is a source of fact"). The path (`identify.py`) is: diff --git a/docs/adr/0017-offline-static-vector-semantic-embedding-provider.md b/docs/adr/0017-offline-static-vector-semantic-embedding-provider.md index 052fbc1..b573b3b 100644 --- a/docs/adr/0017-offline-static-vector-semantic-embedding-provider.md +++ b/docs/adr/0017-offline-static-vector-semantic-embedding-provider.md @@ -42,7 +42,7 @@ selected via `retrieval.embedding_provider: static`: repeated SHA-256 hashing of the cluster's name (stable across machines and Python versions, unlike seeding `random.Random`); each token in the cluster's terms — tokenized with the exact same `content_tokens()` pipeline retrieval and grounding already use — - gets the L2-normalised sum of the cluster vectors it participates in. The output is + gets the L2-normalized sum of the cluster vectors it participates in. The output is committed, versioned data, regenerated the same way the corpus index is regenerated by `make ingest` — a build step, not a runtime dependency. - **Total coverage via a hashing fallback.** A token absent from the curated table (most diff --git a/docs/adr/0021-coverage-risk-curve-in-calibration-report.md b/docs/adr/0021-coverage-risk-curve-in-calibration-report.md index 23a9c55..8ea11a5 100644 --- a/docs/adr/0021-coverage-risk-curve-in-calibration-report.md +++ b/docs/adr/0021-coverage-risk-curve-in-calibration-report.md @@ -38,8 +38,8 @@ returns one `CoveragePoint(threshold, coverage, risk, n_covered)` per threshold: The `calibration` eval suite (`eval/suites/calibration.py`) computes this curve alongside the existing reliability bins and appends it to `SuiteResult.segments` — the same generic mechanism the reliability diagram already uses, so `report.py` needed no changes to render -it. Each row is labelled `risk @ confidence≥T (coverage C)`: `T` is the confidence cutoff -and `C` the coverage measured at it. Labelling the row `coverage≥T` instead — as the first +it. Each row is labeled `risk @ confidence≥T (coverage C)`: `T` is the confidence cutoff +and `C` the coverage measured at it. Labeling the row `coverage≥T` instead — as the first draft did — named the cutoff after the wrong axis and contradicted the numbers beside it (at `confidence≥0.25` the committed corpus covers 100% of calibration cases, not 25%), and left coverage, half of what E4 asks the report to publish, nowhere in the table. Each point additionally carries an informational PASS/FAIL: FAIL when risk rises more diff --git a/docs/audits/gate-inventory.md b/docs/audits/gate-inventory.md index eec6563..4e02865 100644 --- a/docs/audits/gate-inventory.md +++ b/docs/audits/gate-inventory.md @@ -22,7 +22,7 @@ An `AUTO` row that cannot be resolved is a declared-but-unenforced gate. | AI evaluation suites — `AI-EVALUATION-STANDARD` | **conversation** (a follow-up resolves species/topic from bounded history, and history is never a source) | AUTO | `src/sprout/eval/suites/conversation.py`, replayed through the server's own `SessionMemory`; a history-injection case that changes which chunks ground the answer is an outright failure | ✅ resolved | | AI evaluation suites — `AI-EVALUATION-STANDARD` | **toxicity-coverage** (every ASPCA-listed pet-toxic plant the corpus covers carries a routed `## Toxicity` section) | AUTO | `src/sprout/eval/suites/toxicity_coverage.py` — corpus-level, so no lucky generation can satisfy it | ✅ resolved | | AI evaluation suites — `AI-EVALUATION-STANDARD` | Abstention enforced below threshold | AUTO | `eval/suites/calibration` invariant | ✅ resolved | -| AI evaluation suites — `AI-EVALUATION-STANDARD` | EN/ES pass-rate parity (aggregate delta between languages) | AUTO (wired 2026-09-05, closing the 2026-08-29 correction below). Was **not implemented**: the row declared AUTO and named the bilingual slice, but `eval/suites/multilingual` gates *per-case* structural parity at ≥ 0.85 (the row above), a **different quantity** — it only ever scores the non-reference member of a pair and never scores the EN anchors — and no code computed the delta. It does now, as its own suite with its own written metric, so the two quantities cannot be conflated again. **Measured on the committed run: 0.011 (1.1 pp) — EN 0.826 (n=121) vs ES 0.838 (n=37) — inside the target. Read it with the caveats the suite itself publishes: the 95% Newcombe interval on the gap is [0.000, 0.127], so this corpus cannot yet rule out a gap far above 5 pp, and the EN/ES case sets are not matched, so the pooled figure carries a case-mix component. The suite's report-only stratum diagnostics all exceed 5 pp (matched pairs 0.083, behaviour=answer 0.068, behaviour=refuse-and-redirect 0.052). The gate is the aggregate this row declares; deepening the 37-case ES slice so the aggregate can carry more weight is separate, still-outstanding work.** | `src/sprout/eval/suites/language_parity.py` (the `language-parity` suite, metric `en-es-pass-rate-gap`) | ✅ resolved | +| AI evaluation suites — `AI-EVALUATION-STANDARD` | EN/ES pass-rate parity (aggregate delta between languages) | AUTO (wired 2026-09-05, closing the 2026-08-29 correction below). Was **not implemented**: the row declared AUTO and named the bilingual slice, but `eval/suites/multilingual` gates *per-case* structural parity at ≥ 0.85 (the row above), a **different quantity** — it only ever scores the non-reference member of a pair and never scores the EN anchors — and no code computed the delta. It does now, as its own suite with its own written metric, so the two quantities cannot be conflated again. **Measured on the committed run: 0.011 (1.1 pp) — EN 0.826 (n=121) vs ES 0.838 (n=37) — inside the target. Read it with the caveats the suite itself publishes: the 95% Newcombe interval on the gap is [0.000, 0.127], so this corpus cannot yet rule out a gap far above 5 pp, and the EN/ES case sets are not matched, so the pooled figure carries a case-mix component. The suite's report-only stratum diagnostics all exceed 5 pp (matched pairs 0.083, behavior=answer 0.068, behavior=refuse-and-redirect 0.052). The gate is the aggregate this row declares; deepening the 37-case ES slice so the aggregate can carry more weight is separate, still-outstanding work.** | `src/sprout/eval/suites/language_parity.py` (the `language-parity` suite, metric `en-es-pass-rate-gap`) | ✅ resolved | | AI evaluation suites — `AI-EVALUATION-STANDARD` | Hallucination rate | AUTO | `tests/test_rag.py` citation-guard tests + `eval/suites/groundedness` | ✅ resolved | | AI evaluation suites — `AI-EVALUATION-STANDARD` | Judge ↔ human agreement (deterministic judge, CI floor) | **AUTO (gated 2026-07-08, P0-4) — probe set expanded 12 → 66 (within the 50–100 target) across all 16 corpus species, and the judge’s negation-only polarity guard gained `has_antonym_conflict` for antonym flips carrying no negation marker ("safe" vs "toxic"); measured on the combined set: agreement 0.955, κ 0.906, both clear threshold; the CI/`make verify` step passes `--gate` and fails the build on regression. This gates the reproducible offline judge as a coverage/polarity smoke-floor only — morphological synonyms and low-overlap paraphrase remain documented blind spots (3 known disagreements are committed, not hidden). See `docs/audits/judge-calibration.md`.** | `sprout calibrate --gate` on the dated probe set | — exempt (not an unconditional AUTO claim) | | AI evaluation suites — `AI-EVALUATION-STANDARD` | Judge ↔ human agreement (LLM judge, production gate) | **not yet done — the LLM judge needs a live Anthropic credential and is deliberately never invoked in CI (see `eval/llm_judge.py`); calibrating and gating it (`--judge llm --gate`) before it backs any production judging decision is a separate, still-outstanding step, tracked here rather than conflated with the deterministic-judge CI floor above.** | `sprout calibrate --judge llm` | — exempt (not an unconditional AUTO claim) | diff --git a/docs/cards/model-card.md b/docs/cards/model-card.md index ccf61c7..7ebd846 100644 --- a/docs/cards/model-card.md +++ b/docs/cards/model-card.md @@ -132,7 +132,7 @@ guard → confidence/abstention → answer-or-refuse`. ### Offline deterministic stack (default — `provider: deterministic`) - **Embedding:** `HashingEmbedding` — a signed SHA-256 token-hashing bag-of-tokens projection, - L2-normalised, 512-d. SHA-256 is used purely as a stable token→dimension map (a non-cryptographic + L2-normalized, 512-d. SHA-256 is used purely as a stable token→dimension map (a non-cryptographic use). The same text always yields a byte-identical vector, so the index is reproducible in CI. It is a retrieval *baseline*, not a semantic model. - **Retrieval:** pure-Python **Okapi BM25** fused with dense cosine via **Reciprocal Rank Fusion**, diff --git a/docs/plans/improvement-plan.md b/docs/plans/improvement-plan.md index b01eb2a..607fdd5 100644 --- a/docs/plans/improvement-plan.md +++ b/docs/plans/improvement-plan.md @@ -184,7 +184,7 @@ every new guard is broken deliberately before it is trusted. root is left out. 4. **Issue #107**: `answer.py` and `answer.ts` route off a shared bilingual slug set rather than the literal `"toxicity"`, with the Spanish reproduction as a - test and the English behaviour pinned so the fix cannot pass by routing + test and the English behavior pinned so the fix cannot pass by routing everything. 5. **Issue #108**: the export carries `confidence.fit`, the TS config declares it, `scoreConfidence` reads it with the current constants as the fallback, diff --git a/scripts/generate_static_vectors.py b/scripts/generate_static_vectors.py index 247c446..95135f3 100644 --- a/scripts/generate_static_vectors.py +++ b/scripts/generate_static_vectors.py @@ -8,7 +8,7 @@ which is not guaranteed stable across Python versions). 2. Tokenizes every term in the cluster with the *same* ``content_tokens()`` pipeline retrieval and grounding use, so lookups at query time hit the same keys. -3. Assigns each resulting token the L2-normalised sum of the seed vectors of every +3. Assigns each resulting token the L2-normalized sum of the seed vectors of every cluster it appears in (a token that legitimately belongs to more than one concept lands between them rather than arbitrarily picking one). @@ -68,7 +68,7 @@ def _add(a: list[float], b: list[float]) -> list[float]: def _normalize(vec: list[float]) -> list[float]: - """L2-normalise, using ``math.sqrt`` and never ``x ** 0.5``. + """L2-normalize, using ``math.sqrt`` and never ``x ** 0.5``. ``x ** 0.5`` is libm's ``pow``, which IEEE 754 does not require to be correctly rounded; ``math.sqrt`` is ``sqrt``, which it does. Measured 2026-09-01 on macOS @@ -110,7 +110,7 @@ def build_table() -> dict[str, object]: def render() -> str: - """The exact bytes ``main()`` writes. One serialiser, so ``--check`` cannot drift + """The exact bytes ``main()`` writes. One serializer, so ``--check`` cannot drift from the writer it is checking.""" return json.dumps(build_table(), indent=2, sort_keys=True) + "\n" diff --git a/src/sprout/confidence.py b/src/sprout/confidence.py index b1cf3c0..273d973 100644 --- a/src/sprout/confidence.py +++ b/src/sprout/confidence.py @@ -3,7 +3,7 @@ Confidence is a transparent function of *retrieval evidence* — how strongly the best passage matched and how cleanly it separated from the runner-up — mapped through a fixed logistic. It deliberately does not depend on answer fluency (which would reward confident -nonsense). Two thresholds turn the score into behaviour: below ``abstain_threshold`` the +nonsense). Two thresholds turn the score into behavior: below ``abstain_threshold`` the assistant refuses rather than guesses; below ``low_confidence_threshold`` it answers but flags the answer for human review. The reliability diagram and Expected Calibration Error let the eval harness check that these stated confidences actually track correctness. diff --git a/src/sprout/corpus_bundle.py b/src/sprout/corpus_bundle.py index 20e6731..37edacb 100644 --- a/src/sprout/corpus_bundle.py +++ b/src/sprout/corpus_bundle.py @@ -118,7 +118,7 @@ def _require_safe_relpath(rel: str, *, prefix: str | None) -> None: def parse_manifest(raw_yaml_bytes: bytes) -> BundleManifest: """Parse and validate ``manifest.yaml`` bytes. Raises ``BundleError`` on any problem. - A YAML mapping with an unrecognised top-level key (``guards``, ``config``, + A YAML mapping with an unrecognized top-level key (``guards``, ``config``, ``routing``, or anything else not in ``BundleManifest``) fails here, by construction — that is the enforcement, not a denylist of bad keys. """ diff --git a/src/sprout/corpus_diff.py b/src/sprout/corpus_diff.py index a4d8ab9..ebbbed9 100644 --- a/src/sprout/corpus_diff.py +++ b/src/sprout/corpus_diff.py @@ -28,7 +28,7 @@ reported too. **An analysis that could not run says so.** If the eval dataset will not load, the eval -impact is reported as not analysed, with the reason. It is never reported as zero cases +impact is reported as not analyzed, with the reason. It is never reported as zero cases affected. The same holds for the claims registry and for a missing toxicity table. A count of zero and an analysis that did not happen are different facts, and only one of them is reassuring. @@ -302,6 +302,7 @@ class ClaimsImpact(_Frozen): printing a reassuring zero. """ + # `analysed` field names keep their spelling: they are keys in `sprout corpus diff --json`. analysed: bool not_analysed_reason: str = "" corpus_derived: tuple[str, ...] = () @@ -330,6 +331,7 @@ class CorpusDiff(_Frozen): documents: tuple[DocumentChange, ...] chunks: tuple[ChunkChange, ...] toxicity: tuple[ToxicityChange, ...] + # Kept as `analysed`: a key in `sprout corpus diff --json` output (see ClaimsImpact). toxicity_analysed: bool toxicity_not_analysed_reason: str = "" @@ -759,10 +761,10 @@ def _table(lines: list[str], header: list[str], rows: list[list[str]]) -> None: def _render_section(lines: list[str], section: ImpactSection, what: str) -> bool: - """Write the not-analysed notice if there is one. True when the section ran.""" + """Write the not-analyzed notice if there is one. True when the section ran.""" if section.analysed: return True - lines.append(f"**Not analysed.** {section.not_analysed_reason}") + lines.append(f"**Not analyzed.** {section.not_analysed_reason}") lines.append("") lines.append( f"No {what} is reported as unaffected here, because none was examined. " @@ -833,7 +835,7 @@ def _render_toxicity(lines: list[str], diff: CorpusDiff) -> None: lines.append("## Toxicity table") lines.append("") if not diff.toxicity_analysed: - lines.append(f"**Not analysed.** {diff.toxicity_not_analysed_reason}") + lines.append(f"**Not analyzed.** {diff.toxicity_not_analysed_reason}") lines.append("") lines.append( "No toxicity row is reported as unchanged here. A missing table is not a " @@ -926,7 +928,7 @@ def _render_claims(lines: list[str], diff: CorpusDiff) -> None: lines.append("## Claims registry") lines.append("") if not diff.claims.analysed: - lines.append(f"**Not analysed.** {diff.claims.not_analysed_reason}") + lines.append(f"**Not analyzed.** {diff.claims.not_analysed_reason}") lines.append("") return if diff.claims.corpus_derived: diff --git a/src/sprout/corpus_registry.py b/src/sprout/corpus_registry.py index f9b6b44..b5939c7 100644 --- a/src/sprout/corpus_registry.py +++ b/src/sprout/corpus_registry.py @@ -85,7 +85,7 @@ def _open_bundle(bundle_path: Path, max_bytes: int) -> tarfile.TarFile: def _safe_members(tf: tarfile.TarFile, max_member_bytes: int) -> dict[str, tarfile.TarInfo]: """Validate every archive member before anything is read: no absolute paths or ``..`` traversal, no symlinks/devices/etc., only the fixed bundle layout, and a - per-member size cap (defence against a decompression bomb).""" + per-member size cap (defense against a decompression bomb).""" out: dict[str, tarfile.TarInfo] = {} for m in tf.getmembers(): name = m.name diff --git a/src/sprout/determinism.py b/src/sprout/determinism.py index 16004bf..cd73bc3 100644 --- a/src/sprout/determinism.py +++ b/src/sprout/determinism.py @@ -15,7 +15,7 @@ from typing import Any # Fixed seed used across the eval harness; 1729 is the Hardy-Ramanujan number, used -# purely as a memorable constant so reviewers recognise it as "the project seed". +# purely as a memorable constant so reviewers recognize it as "the project seed". DEFAULT_SEED = 1729 @@ -36,7 +36,7 @@ def sha256_of_bytes(data: bytes) -> str: def sha256_of_obj(obj: Any) -> str: - """Hex SHA-256 of any JSON-serialisable object via its canonical encoding.""" + """Hex SHA-256 of any JSON-serializable object via its canonical encoding.""" return sha256_of_bytes(canonical_bytes(obj)) diff --git a/src/sprout/disagreement.py b/src/sprout/disagreement.py index e2b8221..b183f34 100644 --- a/src/sprout/disagreement.py +++ b/src/sprout/disagreement.py @@ -87,7 +87,7 @@ def numeric_cadence_conflicts( checked for a cadence mention of the same care action but a different day-value. One :class:`SourceDisagreement` is returned per (chunk pair, action), deduplicated so a conflict found from either side is only reported once. Never averages the two - values and never drops one in favour of the other — both citations are always + values and never drops one in favor of the other — both citations are always carried together. """ topic_by_chunk_id = {rc.chunk.chunk_id: rc.chunk.topic for rc in retrieved} diff --git a/src/sprout/eval/stats.py b/src/sprout/eval/stats.py index c2dc815..cc1dac3 100644 --- a/src/sprout/eval/stats.py +++ b/src/sprout/eval/stats.py @@ -21,7 +21,7 @@ def wilson_interval(successes: int, n: int, z: float = Z_95) -> tuple[float, flo return (0.0, 0.0) phat = successes / n denom = 1.0 + z * z / n - centre = phat + z * z / (2 * n) + center = phat + z * z / (2 * n) # `math.sqrt`, not `** 0.5`: `pow` is not required to be correctly rounded and # `sqrt` is, so the two differ in the last bit on some inputs and on some platforms # (99 of the 80600 (successes, n) pairs with n<=400, measured on macOS 2026-09-01). @@ -29,8 +29,8 @@ def wilson_interval(successes: int, n: int, z: float = Z_95) -> tuple[float, flo # against a fresh regeneration, so a last-bit platform difference here is a red build # on a file nobody edited. margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) - low = (centre - margin) / denom - high = (centre + margin) / denom + low = (center - margin) / denom + high = (center + margin) / denom return (max(0.0, low), min(1.0, high)) @@ -41,7 +41,7 @@ def wilson_difference_interval( Returns (low, high) for ``p_a - p_b``. Each proportion gets its own Wilson interval and the two are squares-and-added, so the difference inherits Wilson's small-sample - behaviour instead of a normal approximation that misbehaves as either rate approaches + behavior instead of a normal approximation that misbehaves as either rate approaches 0 or 1 — which is exactly the regime a pass-rate slice lives in. An empty slice returns the vacuous [-1, 1]: a difference between something and nothing diff --git a/src/sprout/eval/suites/language_parity.py b/src/sprout/eval/suites/language_parity.py index 53d052d..ddc064c 100644 --- a/src/sprout/eval/suites/language_parity.py +++ b/src/sprout/eval/suites/language_parity.py @@ -31,7 +31,7 @@ load-bearing can see the answer in the same table, exactly as the calibration suite publishes its report-only coverage/risk curve beside its gated ECE. -Fail-closed: fewer than two language slices carrying labelled cases is a FAIL, never a +Fail-closed: fewer than two language slices carrying labeled cases is a FAIL, never a 0.0 gap. "Only one language was evaluated" and "both languages passed equally" are not the same result and must not render as the same number. """ @@ -130,10 +130,10 @@ class LanguageParitySuite: ) def run(self, ctx: EvalContext) -> SuiteResult: - labelled = [ + labeled = [ item for item in ctx.dataset.items if item.language and item.is_correct is not None ] - counts = _tally(labelled) + counts = _tally(labeled) rates = _rates(counts) if len(rates) < 2: return fail_closed( @@ -142,7 +142,7 @@ def run(self, ctx: EvalContext) -> SuiteResult: dataset_version=ctx.dataset.version, judge=ctx.judge, reason=( - "a pass-rate gap needs at least two language slices carrying labelled " + "a pass-rate gap needs at least two language slices carrying labeled " f"cases; found {sorted(rates) or 'none'}" ), ) @@ -168,7 +168,7 @@ def run(self, ctx: EvalContext) -> SuiteResult: "to that slice's pass rate, not itself a parity failure" ), ) - for item in labelled + for item in labeled ] slice_segments = [ SegmentScore( @@ -201,7 +201,7 @@ def run(self, ctx: EvalContext) -> SuiteResult: ci_override=(gap_low, gap_high), underpowered_override=smallest_slice < UNDERPOWERED_N, notes=notes, - segments=slice_segments + _diagnostic_segments(labelled), + segments=slice_segments + _diagnostic_segments(labeled), ) diff --git a/src/sprout/eval/suites/safety.py b/src/sprout/eval/suites/safety.py index 7e192f6..c7754c5 100644 --- a/src/sprout/eval/suites/safety.py +++ b/src/sprout/eval/suites/safety.py @@ -57,7 +57,7 @@ def run(self, ctx: EvalContext) -> SuiteResult: outcomes: list[ExampleOutcome] = [] for item in ctx.dataset.items: # Only toxicity/ingestion cases participate; a generic out-of-scope refusal - # (e.g. "how do I rotate my car tyres?") is the refusal suite's job, not this one. + # (e.g. "how do I rotate my car tires?") is the refusal suite's job, not this one. if not item.is_toxicity_query: continue notice = item.target_response.safety_notice if item.target_response else None @@ -72,7 +72,7 @@ def run(self, ctx: EvalContext) -> SuiteResult: # assistant into certifying a non-toxic control safe: the one case in the suite # most specifically about this check was one of the four that could not run it. cert_ok = not has_any(full, _CERTIFICATION_TERMS) - # A case's own `forbidden_terms` are still honoured, as an addition with its own + # A case's own `forbidden_terms` are still honored, as an addition with its own # verdict line, so the two meanings stay distinguishable in the failure detail. case_terms = list(item.forbidden_terms or ()) case_terms_ok = not has_any(full, case_terms) diff --git a/src/sprout/guards.py b/src/sprout/guards.py index ce8b907..053db69 100644 --- a/src/sprout/guards.py +++ b/src/sprout/guards.py @@ -168,10 +168,10 @@ def _fold_homoglyphs(text: str) -> str: def _fold(text: str) -> str: - """Normalise for safety matching: lower-case, collapse space, fold accents/hyphens/homoglyphs. + """Normalize for safety matching: lower-case, collapse space, fold accents/hyphens/homoglyphs. Accent- and hyphen-folding keep the deny-list robust to "non-toxic" vs "non toxic" and - Spanish accent variants, consistent with how the rest of the pipeline tokenises. + Spanish accent variants, consistent with how the rest of the pipeline tokenizes. Homoglyph-folding closes the Cyrillic-lookalike bypass found by property-based fuzzing (FIX-05); it is intentionally narrow, see ``_HOMOGLYPHS`` above. """ diff --git a/src/sprout/identify.py b/src/sprout/identify.py index e5efbbf..1d292b5 100644 --- a/src/sprout/identify.py +++ b/src/sprout/identify.py @@ -5,11 +5,11 @@ The resolved species is then routed back through the unchanged grounded care-RAG (``Assistant.answer``), so every rendered claim is still retrieval-mandatory, cited, and run through the never-certify-safe guard. The visual identification is surfaced -separately and labelled "a visual match, not a cited fact"; it never enters +separately and labeled "a visual match, not a cited fact"; it never enters ``Answer.sentences`` and is never presented as grounded. This mirrors the Family-Greenhouse contract already documented in the README: an -external signal may *select and personalise*, but only the cited corpus is a source of +external signal may *select and personalize*, but only the cited corpus is a source of fact. If the identifier is offline/unavailable, returns nothing, scores below ``min_confidence``, or names a species the corpus does not cover, the path degrades to a graceful "type the plant's name" fallback rather than guessing. @@ -66,7 +66,7 @@ class ResolvedSpecies(_Frozen): class IdentifiedAnswer(_Frozen): - """What the photo path returns: the (labelled) identification plus a grounded answer. + """What the photo path returns: the (labeled) identification plus a grounded answer. When ``identified`` is false, ``answer`` is ``None`` and ``message`` carries the localized "type the plant's name" fallback. When true, ``answer`` is an ordinary diff --git a/src/sprout/ingest.py b/src/sprout/ingest.py index d618bcc..8bfaac3 100644 --- a/src/sprout/ingest.py +++ b/src/sprout/ingest.py @@ -102,7 +102,7 @@ def build_index(config: Config) -> VectorStore: BM25 postings are built here — once, over every chunk — and persisted in ``index.json`` by ``store.save`` (FIX-07: see ``docs/ideation/02-large-scale-fixes.md``). - No query pays to retokenise the corpus at request time. + No query pays to retokenize the corpus at request time. """ embedder = build_embedding(config) store = VectorStore() diff --git a/src/sprout/lexical.py b/src/sprout/lexical.py index 0d7fe7d..e51896f 100644 --- a/src/sprout/lexical.py +++ b/src/sprout/lexical.py @@ -1,6 +1,6 @@ """Pure-Python BM25 (Okapi) lexical index — the lexical half of hybrid retrieval. -Zero dependencies, fully deterministic. It tokenises with the *same* ``content_tokens`` +Zero dependencies, fully deterministic. It tokenizes with the *same* ``content_tokens`` as the dense embedder and the extractive generator, so a passage that ranks well lexically is described by the same vocabulary the generator will quote and the judge will check. BM25 catches exact-term matches (species names, "10 days") that a bag-of- @@ -92,10 +92,10 @@ def ranking(self, query: str) -> list[int]: # --- persistence ------------------------------------------------------------- # The postings (inverted term -> {doc: tf}, idf, lengths, avg length) are exactly # what ``scores``/``ranking`` need; persisting them lets ``from_state`` reconstruct - # an index without re-tokenising every document, which is the per-query cost this + # an index without re-tokenizing every document, which is the per-query cost this # class exists to avoid once the corpus is indexed at ingest time (FIX-07). def to_state(self) -> dict[str, object]: - """Serialise the fitted postings — everything needed to score without retokenising.""" + """Serialize the fitted postings — everything needed to score without retokenizing.""" return { "k1": self.k1, "b": self.b, @@ -111,7 +111,7 @@ def to_state(self) -> dict[str, object]: @classmethod def from_state(cls, state: dict[str, Any]) -> BM25Index: - """Reconstruct an index from ``to_state`` output — no document retokenisation.""" + """Reconstruct an index from ``to_state`` output — no document retokenization.""" self = cls.__new__(cls) self.k1 = float(state["k1"]) self.b = float(state["b"]) diff --git a/src/sprout/propose.py b/src/sprout/propose.py index 12f4681..899c41f 100644 --- a/src/sprout/propose.py +++ b/src/sprout/propose.py @@ -36,7 +36,7 @@ Mechanically clean, and either not safety-bearing or carrying a committed ``expert_review`` sign-off artifact. -The representational-harm checklist is not box-ticking: every acknowledgement a machine +The representational-harm checklist is not box-ticking: every acknowledgment a machine *can* falsify is cross-checked. ``no_medicinal_or_edibility_claims`` is checked against a deterministic EN/ES claim vocabulary; the never-certify-"safe" rule is checked by running the shipped :func:`sprout.guards.asserts_safety` over every proposed sentence, so a @@ -213,7 +213,7 @@ class ProposalDocument(_Strict): class HarmChecklist(_Strict): """The representational-harm checklist E5 asks for. - Every acknowledgement must be ``true``. Where a machine can falsify one it does + Every acknowledgment must be ``true``. Where a machine can falsify one it does (see :func:`_claim_findings` and :func:`_safety_certification_findings`), so an affirmed box that the text contradicts fails the review rather than passing quietly. """ @@ -760,7 +760,7 @@ def _safety_certification_findings(config: Config, documents: list[Document]) -> def _claim_findings(proposal: Proposal, documents: list[Document]) -> list[Finding]: - """Falsify the ``no_medicinal_or_edibility_claims`` acknowledgement where possible.""" + """Falsify the ``no_medicinal_or_edibility_claims`` acknowledgment where possible.""" findings: list[Finding] = [] for doc, sentence in _sentences(documents): match = _CLAIM_RE.search(strip_accents(sentence).lower()) diff --git a/src/sprout/providers/base.py b/src/sprout/providers/base.py index d065f44..c49a868 100644 --- a/src/sprout/providers/base.py +++ b/src/sprout/providers/base.py @@ -28,13 +28,13 @@ def l2_normalize(vec: list[float]) -> list[float]: runner did not have to give the same vector. That matters twice over. ``web-static/src/hashEmbedding.ts``, the port that runs the - live site, normalises with ``Math.sqrt``: the browser and the CLI were using different + live site, normalizes with ``Math.sqrt``: the browser and the CLI were using different square roots for the same claimed-identical pipeline. And the committed eval artifacts are now byte-compared against a fresh regeneration (#122), which turns any last-bit platform difference into a red build on a file nobody edited — as it already did once, for ``static_vectors.json``. - Every embedder normalises through here so there is one square root to be right about. + Every embedder normalizes through here so there is one square root to be right about. """ norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm else vec @@ -42,7 +42,7 @@ def l2_normalize(vec: list[float]) -> list[float]: @runtime_checkable class EmbeddingProvider(Protocol): - """Maps text to a fixed-length, L2-normalised dense vector.""" + """Maps text to a fixed-length, L2-normalized dense vector.""" @property def dim(self) -> int: ... diff --git a/src/sprout/providers/bedrock.py b/src/sprout/providers/bedrock.py index 18b3ba2..3ba2b68 100644 --- a/src/sprout/providers/bedrock.py +++ b/src/sprout/providers/bedrock.py @@ -37,7 +37,7 @@ def _client(region: str) -> Any: class TitanEmbedding: - """Amazon Titan text embeddings (returns an L2-normalised vector).""" + """Amazon Titan text embeddings (returns an L2-normalized vector).""" def __init__(self, dim: int = 512, region: str = "us-west-2", client: Any = None) -> None: self._dim = dim diff --git a/src/sprout/providers/deterministic.py b/src/sprout/providers/deterministic.py index fc9b23a..61be6b7 100644 --- a/src/sprout/providers/deterministic.py +++ b/src/sprout/providers/deterministic.py @@ -18,7 +18,7 @@ class HashingEmbedding: - """Signed token-hashing bag-of-tokens projection, L2-normalised. + """Signed token-hashing bag-of-tokens projection, L2-normalized. Each content token is hashed with SHA-256; the first 4 bytes pick a dimension and the next bit picks a sign. SHA-256 (not SHA-1) is used purely as a stable @@ -50,7 +50,7 @@ class ExtractiveGenerator: Selection is facet-coverage aware: the query is split into clauses (``text. extract_facets``) and, after ranking every candidate sentence by query overlap as - before, sentences are picked greedily to maximise *marginal* facet coverage first + before, sentences are picked greedily to maximize *marginal* facet coverage first and raw score second. A single-clause query ("How often should I water my pothos?") degrades to plain top-score selection — unchanged from before this was added. A multi-clause query ("How often should I water, and does that change in winter?") @@ -136,11 +136,11 @@ def _score_candidates( def _select_diverse( candidates: list[tuple[float, str, str, frozenset[int]]], max_sentences: int ) -> list[tuple[str, str]]: - """Greedily pick sentences maximising marginal facet coverage, then score. + """Greedily pick sentences maximizing marginal facet coverage, then score. With a single facet (or none), every candidate's marginal coverage is identical on the first pick and zero thereafter, so this reduces to plain - top-score selection — the pre-facet-coverage behaviour is unchanged. + top-score selection — the pre-facet-coverage behavior is unchanged. """ out: list[tuple[str, str]] = [] covered_facets: set[int] = set() diff --git a/src/sprout/retrieve.py b/src/sprout/retrieve.py index 80680c9..8be4d64 100644 --- a/src/sprout/retrieve.py +++ b/src/sprout/retrieve.py @@ -14,7 +14,7 @@ Two scale properties matter as the corpus grows past a few hundred chunks (FIX-07, ``docs/ideation/02-large-scale-fixes.md``): the BM25 index is built **once** per ``Retriever`` (from the store's persisted postings when available, or lazily on first -use otherwise) instead of being retokenised on every query; and the dense vector scan is +use otherwise) instead of being retokenized on every query; and the dense vector scan is bounded — to the named species' chunk-id set when the query scopes to one, or to a generous fixed fan-out otherwise — instead of always sorting the entire store. """ @@ -132,7 +132,7 @@ def __init__(self, config: Config, store: VectorStore, embedder: EmbeddingProvid self._by_chunk_id: dict[str, Chunk] = {c.chunk_id: c for c in self._chunks} # Pre-group chunk ids by species slug, and precompute each slug's distinctive - # token set, once per Retriever — so `_candidates` never re-scans/re-tokenises + # token set, once per Retriever — so `_candidates` never re-scans/re-tokenizes # the whole corpus on a per-query basis. self._chunk_ids_by_slug: dict[str, list[str]] = {} self._distinctive_by_slug: dict[str, frozenset[str]] = {} diff --git a/src/sprout/review.py b/src/sprout/review.py index 95717c6..7f3153f 100644 --- a/src/sprout/review.py +++ b/src/sprout/review.py @@ -57,7 +57,7 @@ # A label counts as "what shipped was right" for exporters that need a boolean (judge-probe # human_label, calibration is_correct). Only "correct" passes -- every other label, # including "should-have-refused" (the assistant answered when it should have abstained), -# is a negative signal about the shipped behaviour. +# is a negative signal about the shipped behavior. _POSITIVE_LABELS: frozenset[str] = frozenset({"correct"}) diff --git a/src/sprout/site_meta.py b/src/sprout/site_meta.py index 3ac9d38..afe3d36 100644 --- a/src/sprout/site_meta.py +++ b/src/sprout/site_meta.py @@ -181,7 +181,7 @@ def _check_card(name: str, values: dict[str, str], root: Path, origin: str) -> l """A declared card image must be same-origin, absolute, and really published. An unfurler fetches this URL once and caches whatever came back, so a card - pointing at a file the build did not write degrades to the blank grey box the + pointing at a file the build did not write degrades to the blank gray box the tag was added to prevent — and does it silently, because nothing on the page is broken. The same-origin rule was already here; what was missing is the half that costs nothing to check and is the half that actually goes wrong. diff --git a/src/sprout/store.py b/src/sprout/store.py index 6672c5e..6aa2b34 100644 --- a/src/sprout/store.py +++ b/src/sprout/store.py @@ -1,9 +1,9 @@ """In-memory cosine vector store, JSON-persistable — the default index. -No database: the whole index is a list of (chunk, vector) pairs that serialises to one -JSON file and rebuilds from ``make ingest``. Vectors are stored pre-normalised, so cosine +No database: the whole index is a list of (chunk, vector) pairs that serializes to one +JSON file and rebuilds from ``make ingest``. Vectors are stored pre-normalized, so cosine similarity is a dot product. The store also carries the corpus's **BM25 postings**, built -once (at ingest, or lazily on first use) rather than re-tokenised on every query — see +once (at ingest, or lazily on first use) rather than re-tokenized on every query — see ``build_bm25``/``bm25`` and FIX-07 in ``docs/ideation/02-large-scale-fixes.md``. There is no mutable server state to lose; recovery is "re-ingest". """ @@ -25,7 +25,7 @@ class VectorStore: - """A flat cosine store over pre-normalised dense vectors, plus BM25 postings.""" + """A flat cosine store over pre-normalized dense vectors, plus BM25 postings.""" def __init__(self) -> None: self._chunks: list[Chunk] = [] @@ -51,7 +51,7 @@ def build_bm25(self, *, k1: float = 1.5, b: float = 0.75) -> BM25Index: """Build BM25 postings over every stored chunk, in store order. Intended to run **once**, at ingest time, so ``index.json`` carries the postings - and no query ever pays for re-tokenising the corpus. Idempotent — safe to call + and no query ever pays for re-tokenizing the corpus. Idempotent — safe to call again (e.g. after a lazy fallback) since it only reads already-stored chunk text. """ self._bm25 = BM25Index([c.text for c in self._chunks], k1=k1, b=b) @@ -69,7 +69,7 @@ def search( *, candidate_ids: Iterable[str] | None = None, ) -> list[RetrievedChunk]: - """Top-k chunks by cosine similarity (dot product on normalised vectors). + """Top-k chunks by cosine similarity (dot product on normalized vectors). ``candidate_ids``, when given, bounds the scan to those chunks instead of the whole store — the species/topic filter in ``Retriever`` uses this so a scoped diff --git a/src/sprout/text.py b/src/sprout/text.py index b1421fc..8badce3 100644 --- a/src/sprout/text.py +++ b/src/sprout/text.py @@ -13,7 +13,7 @@ import unicodedata # Bilingual stop-word set. Kept deliberately small and explicit (not a downloaded -# corpus) so the behaviour is auditable and stable across releases. +# corpus) so the behavior is auditable and stable across releases. _STOPWORDS: frozenset[str] = frozenset( { # English @@ -211,7 +211,7 @@ # # Equivalent for this module's only consumer: the pieces now keep their surrounding whitespace, # and ``extract_facets`` feeds every piece through ``.strip()`` and ``token_set`` (which -# tokenises with ``_TOKEN_RE``), so leading/trailing whitespace on a clause is not observable. +# tokenizes with ``_TOKEN_RE``), so leading/trailing whitespace on a clause is not observable. # ``\s++`` is possessive because no conjunction begins with whitespace, so a whitespace run # never had a match to give back. _FACET_SPLIT_RE = re.compile( @@ -221,7 +221,7 @@ def strip_accents(token: str) -> str: - """Fold accents so 'también' and 'tambien' tokenise identically.""" + """Fold accents so 'también' and 'tambien' tokenize identically.""" decomposed = unicodedata.normalize("NFKD", token) return "".join(ch for ch in decomposed if not unicodedata.combining(ch)) @@ -239,7 +239,7 @@ def _strip_zero_width(text: str) -> str: to dodge exact-phrase and token matching without changing how the text *looks* or reads aloud. Characters are removed outright (not replaced with a space) so the surrounding letters re-join into the original word instead of being split into spurious sub-tokens. - Applied before tokenisation so every consumer of ``tokenize``/``normalize`` — retrieval, + Applied before tokenization so every consumer of ``tokenize``/``normalize`` — retrieval, the citation guard, and the never-certify-safe deny-list — sees the same de-obfuscated text. """ @@ -296,7 +296,7 @@ def extract_facets(query: str) -> list[frozenset[str]]: """Split a query into per-clause content-token sets ("facets"). A single-part question ("How often should I water my pothos?") yields one facet — - its whole content-token set — so single-part behaviour is unchanged downstream. A + its whole content-token set — so single-part behavior is unchanged downstream. A multi-part question ("How often should I water, and does that change in winter?") yields one facet per clause, so a caller can require each clause's topic to surface in the answer instead of ranking every candidate against one pooled bag of tokens @@ -445,7 +445,7 @@ def contains_phrase(haystack: str, phrase: str) -> bool: "semanas": 7.0, } -# Small, explicit bilingual care-action vocabulary. Each key is the normalised action +# Small, explicit bilingual care-action vocabulary. Each key is the normalized action # name a cadence mention is reported under, so an English chunk and a Spanish chunk # that disagree about the same action still compare equal. _CARE_ACTIONS: dict[str, frozenset[str]] = { @@ -479,9 +479,9 @@ def extract_cadences(text: str) -> list[tuple[str, float, str]]: """Bilingual 'every N day(s)/week(s)' mentions anchored to a known care action. Returns one ``(action, days, mention)`` tuple per anchored match, e.g. - ``[("water", 7.0, "every 7 days")]``. Weeks normalise to days so "every 2 weeks" + ``[("water", 7.0, "every 7 days")]``. Weeks normalize to days so "every 2 weeks" and "every 14 days" compare equal instead of registering as a false conflict. A - cadence with no recognised action word in its surrounding window is dropped — + cadence with no recognized action word in its surrounding window is dropped — unanchored numbers are exactly the over-firing risk this stays conservative about. """ out: list[tuple[str, float, str]] = [] diff --git a/src/sprout/web_bundle.py b/src/sprout/web_bundle.py index 9d1f61a..e3d7116 100644 --- a/src/sprout/web_bundle.py +++ b/src/sprout/web_bundle.py @@ -96,7 +96,7 @@ #: 2 -> 3 when the confidence band's cut point and its localized labels were added. A #: version-2 bundle carries neither, and the browser cannot invent them: a missing #: cutoff compares as ``confidence >= undefined``, which is false for every score, so -#: every answered question would be labelled "partially supported — verify" and a +#: every answered question would be labeled "partially supported — verify" and a #: well-supported answer would be understated with nothing failing. That is a missing #: value rendered as a measurement, so a version-2 bundle is rejected rather than #: defaulted. diff --git a/tests/test_analytics.py b/tests/test_analytics.py index cd53ad5..2d99196 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -222,8 +222,8 @@ def _node() -> str: node = shutil.which("node") if node is None: if os.environ.get("CI"): - pytest.fail("Node is required in CI to run the GA4 loader's behaviour tests") - pytest.skip("Node is not installed; the loader's behaviour tests need it") + pytest.fail("Node is required in CI to run the GA4 loader's behavior tests") + pytest.skip("Node is not installed; the loader's behavior tests need it") return node diff --git a/tests/test_ci_parity.py b/tests/test_ci_parity.py index d0993d3..55e67eb 100644 --- a/tests/test_ci_parity.py +++ b/tests/test_ci_parity.py @@ -392,7 +392,7 @@ def test_every_exemption_carries_a_reason() -> None: # --- the aggregator itself: the ONLY required check, and what it can say --------------- # # Everything above holds the workflow *file* to the registry. These run the aggregator's -# own shipped script, because its two defects were runtime behaviour and neither was +# own shipped script, because its two defects were runtime behavior and neither was # visible in the file: it named the failing gate's *value* rather than its identity, and # its loop had no floor, so an empty `needs:` produced a required check that reported # success having examined nothing. @@ -477,7 +477,7 @@ def test_a_skipped_path_filtered_job_is_still_a_pass() -> None: assert "::error::" not in out, out -def test_a_cancelled_gate_is_not_a_pass_and_is_named() -> None: +def test_a_canceled_gate_is_not_a_pass_and_is_named() -> None: """`cancelled` is no verdict rather than a good one, and this repository has already lost verdicts to concurrency eviction. Both non-success results are named, not just the first.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6324b9e..059f9f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -804,7 +804,7 @@ def test_calibrate_warns_when_labeled_date_missing(tmp_path: Path) -> None: def test_calibrate_gate_refuses_a_freshness_check_that_could_not_run( tmp_path: Path, labeled_date: str | None, fragment: str ) -> None: - """AIEV-20's neighbour: three ways the freshness check silently stops existing. + """AIEV-20's neighbor: three ways the freshness check silently stops existing. `sprout calibrate --gate` is the merge-blocking mode, and the ROADMAP row says probe freshness is "checked by `sprout calibrate`". All three of these used to take the diff --git a/tests/test_committed_artifacts_are_current.py b/tests/test_committed_artifacts_are_current.py index 6c9c7cc..0993751 100644 --- a/tests/test_committed_artifacts_are_current.py +++ b/tests/test_committed_artifacts_are_current.py @@ -200,7 +200,7 @@ def test_every_committed_audit_artifact_is_covered() -> None: def test_static_vector_table_matches_its_generator() -> None: """``src/sprout/data/embeddings/static_vectors.json`` vs ``clusters.yaml``. - Runs the real generator's ``--check`` mode, which renders through the same serialiser + Runs the real generator's ``--check`` mode, which renders through the same serializer ``main()`` writes with and compares without touching the file. Editing ``clusters.yaml`` and forgetting to regenerate previously shipped a table the code no longer produces, with every gate green. @@ -216,13 +216,13 @@ def test_static_vector_table_matches_its_generator() -> None: def _reference_normalize(vec: list[float]) -> list[float]: - """L2-normalise using ``math.sqrt``, the correctly-rounded IEEE 754 square root.""" + """L2-normalize using ``math.sqrt``, the correctly-rounded IEEE 754 square root.""" norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm else vec def _raw_seed(name: str, dim: int) -> list[float]: - """A cluster's hashed byte stream *before* normalisation — mirrors ``_seed_vector``.""" + """A cluster's hashed byte stream *before* normalization — mirrors ``_seed_vector``.""" vec: list[float] = [] counter = 0 while len(vec) < dim: @@ -235,8 +235,8 @@ def _raw_seed(name: str, dim: int) -> list[float]: return vec -def _unnormalised_generator_inputs(generator: ModuleType) -> dict[str, list[float]]: - """Every vector the generator normalises, before it normalises it. +def _unnormalized_generator_inputs(generator: ModuleType) -> dict[str, list[float]]: + """Every vector the generator normalizes, before it normalizes it. The per-cluster hash streams and the per-token sums of seed vectors, keyed by a label that names which one, so a failure says which vector disagreed. @@ -262,7 +262,7 @@ def _unnormalised_generator_inputs(generator: ModuleType) -> dict[str, list[floa def test_the_static_vector_table_is_the_same_bytes_on_every_platform() -> None: - """The generator normalises with ``math.sqrt``, never with ``x ** 0.5``. + """The generator normalizes with ``math.sqrt``, never with ``x ** 0.5``. A committed table plus a regenerate-and-compare gate is only a gate if two machines agree on the arithmetic. ``x ** 0.5`` is libm's ``pow``, which IEEE 754 does not @@ -274,23 +274,23 @@ def test_the_static_vector_table_is_the_same_bytes_on_every_platform() -> None: said "is current" on one machine and "is stale" on the other for a file nobody had edited. - This rebuilds the generator's *un-normalised* inputs from the real ``clusters.yaml`` - and compares the generator's normalisation of each against a ``math.sqrt`` + This rebuilds the generator's *un-normalized* inputs from the real ``clusters.yaml`` + and compares the generator's normalization of each against a ``math.sqrt`` reference, so it fails on any machine whose ``pow`` disagrees rather than only on the one that happened to write the file. """ generator = _load_generator() - inputs = _unnormalised_generator_inputs(generator) + inputs = _unnormalized_generator_inputs(generator) for label, vec in inputs.items(): assert generator._normalize(vec) == _reference_normalize(vec), ( - f"{label} normalises differently from math.sqrt; the generator is using a " + f"{label} normalizes differently from math.sqrt; the generator is using a " "platform-dependent square root, so the committed table is only " "reproducible on the machine that wrote it" ) def test_the_seed_vector_helper_uses_the_same_correctly_rounded_square_root() -> None: - """``_seed_vector`` normalises its own hash stream; that step must not regress. + """``_seed_vector`` normalizes its own hash stream; that step must not regress. It delegates to ``_normalize`` today. It carried its own inline ``** 0.5`` before, which is how the divergence above got in, so this pins the composed result rather @@ -304,7 +304,7 @@ def test_the_seed_vector_helper_uses_the_same_correctly_rounded_square_root() -> raw = _raw_seed(name, generator.DIM) assert generator._seed_vector(name) == _reference_normalize(raw), ( f"the seed vector for cluster {name!r} is not the correctly-rounded " - "normalisation of its hash stream" + "normalization of its hash stream" ) diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 57f3695..6cbd2c7 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1,7 +1,7 @@ """EXP-07: bounded multi-turn conversation context, and the adversarial proof that history can never override a cited fact. -Three layers are tested: (1) ``SessionMemory``'s bounding/eviction behaviour in isolation, +Three layers are tested: (1) ``SessionMemory``'s bounding/eviction behavior in isolation, (2) ``extract_turn`` never captures answer text, and (3) end-to-end through ``Assistant`` and the HTTP API — a follow-up resolves species from history, but a species the current turn names explicitly always wins, and an attempted history-injection can never change which diff --git a/tests/test_corpus_bundle.py b/tests/test_corpus_bundle.py index f0492f2..accf352 100644 --- a/tests/test_corpus_bundle.py +++ b/tests/test_corpus_bundle.py @@ -1,6 +1,6 @@ """``BundleManifest`` schema tests (EXP-15): the enforcement is the schema itself. -A manifest with an unrecognised top-level key (``guards``, ``config``, or anything not +A manifest with an unrecognized top-level key (``guards``, ``config``, or anything not declared on ``BundleManifest``) must fail to parse — that is what makes an installed bundle structurally unable to alter Sprout's own routing/deny-list strings. """ diff --git a/tests/test_corpus_diff.py b/tests/test_corpus_diff.py index 11e6532..3cb4a88 100644 --- a/tests/test_corpus_diff.py +++ b/tests/test_corpus_diff.py @@ -12,7 +12,7 @@ * an edit to one sentence names the chunk, the eval cases whose answers moved, and the smoke questions whose answers moved; * a removed document that a case still cites is an error and a non-zero exit; -* an analysis that could not run is rendered as not analysed, never as zero affected; +* an analysis that could not run is rendered as not analyzed, never as zero affected; * the output is byte-identical across separate interpreters, not only within one. """ @@ -377,7 +377,7 @@ def test_a_toxicity_row_change_is_reported_and_gated(corpora: tuple[Path, Path, assert exit_code_for(diff, fail_on_toxicity_change=True) == 1 -def test_a_missing_toxicity_table_is_not_analysed_rather_than_unchanged( +def test_a_missing_toxicity_table_is_not_analyzed_rather_than_unchanged( tmp_path: Path, ) -> None: """The absence-as-a-value trap: no table is not a table with no changes in it.""" @@ -397,7 +397,7 @@ def test_a_missing_toxicity_table_is_not_analysed_rather_than_unchanged( # --- analyses that could not run ------------------------------------------------ -def test_an_unloadable_eval_dataset_is_not_analysed_rather_than_zero( +def test_an_unloadable_eval_dataset_is_not_analyzed_rather_than_zero( corpora: tuple[Path, Path, Path], tmp_path: Path ) -> None: before, after, _ = corpora @@ -407,11 +407,11 @@ def test_an_unloadable_eval_dataset_is_not_analysed_rather_than_zero( assert not diff.eval_cases assert "could not be loaded" in diff.eval_section.not_analysed_reason rendered = render_markdown(diff) - assert "**Not analysed.**" in rendered + assert "**Not analyzed.**" in rendered assert "An analysis that did not run is not a result of zero." in rendered -def test_a_missing_claims_registry_is_not_analysed(corpora: tuple[Path, Path, Path]) -> None: +def test_a_missing_claims_registry_is_not_analyzed(corpora: tuple[Path, Path, Path]) -> None: before, after, suites = corpora (after / "processed" / "pothos.md").write_text(_POTHOS + "\nExtra sentence.\n", "utf-8") diff = _diff(before, after, suites, claims_path="does-not-exist.yaml") @@ -436,7 +436,7 @@ def test_the_committed_registry_has_no_corpus_derived_claim( assert "gap in the registry" in render_markdown(diff) -def test_the_markdown_says_a_missing_toxicity_table_was_not_analysed(tmp_path: Path) -> None: +def test_the_markdown_says_a_missing_toxicity_table_was_not_analyzed(tmp_path: Path) -> None: before = _write_root(tmp_path / "before", {"pothos.md": _POTHOS}, toxicity=False) after = _write_root(tmp_path / "after", {"pothos.md": _POTHOS + "\nExtra.\n"}, toxicity=False) suites = _write_suites(tmp_path / "eval", _CASES[:1]) @@ -452,7 +452,7 @@ def test_the_markdown_says_a_missing_toxicity_table_was_not_analysed(tmp_path: P ("something_else: 1\n", "has no 'claims' list"), ], ) -def test_an_unreadable_claims_registry_is_not_analysed( +def test_an_unreadable_claims_registry_is_not_analyzed( corpora: tuple[Path, Path, Path], tmp_path: Path, body: str, fragment: str ) -> None: before, after, suites = corpora @@ -509,7 +509,7 @@ def test_a_bad_corpus_root_fails_closed(tmp_path: Path, broken: str, fragment: s # --- the CLI -------------------------------------------------------------------- -def test_cli_reports_an_edit_and_honours_the_toxicity_gate( +def test_cli_reports_an_edit_and_honors_the_toxicity_gate( corpora: tuple[Path, Path, Path], ) -> None: before, after, suites = corpora diff --git a/tests/test_error_response_disclosure.py b/tests/test_error_response_disclosure.py index 59bc53a..68bb9f7 100644 --- a/tests/test_error_response_disclosure.py +++ b/tests/test_error_response_disclosure.py @@ -167,7 +167,7 @@ def test_corrupt_store_does_not_disclose_the_storage_format( def test_rejections_are_still_triageable_from_the_logs( assistant: Assistant, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """Genericising the response must not make the failure invisible to the operator. + """Genericizing the response must not make the failure invisible to the operator. ``obs.Logger`` is PII-free by construction, so the exception *message* — free text that routinely echoes caller input — is deliberately not logged either. The exception class is, diff --git a/tests/test_eval_core.py b/tests/test_eval_core.py index fc516e4..0a058f6 100644 --- a/tests/test_eval_core.py +++ b/tests/test_eval_core.py @@ -215,7 +215,7 @@ def test_cohens_kappa() -> None: def test_cohens_kappa_over_no_observations_is_undefined_not_perfect() -> None: """The two degenerate cases are not the same thing, and this file used to say they were. - `cohens_kappa([], []) == 1.0` was asserted here as intended behaviour. Degenerate + `cohens_kappa([], []) == 1.0` was asserted here as intended behavior. Degenerate *expected* agreement (every label identical, above) is an observation whose kappa is genuinely 1.0. No labels at all is not an observation, and scoring it 1.0 is what made `sprout calibrate --gate` — a merge-blocking CI step — pass over an empty probe file @@ -299,9 +299,9 @@ def _wilson_interval_pow(successes: int, n: int) -> tuple[float, float]: z = Z_95 phat = successes / n denom = 1.0 + z * z / n - centre = phat + z * z / (2 * n) + center = phat + z * z / (2 * n) margin = z * ((phat * (1 - phat) / n + z * z / (4 * n * n)) ** 0.5) - return (max(0.0, (centre - margin) / denom), min(1.0, (centre + margin) / denom)) + return (max(0.0, (center - margin) / denom), min(1.0, (center + margin) / denom)) def _wilson_interval_sqrt(successes: int, n: int) -> tuple[float, float]: @@ -309,9 +309,9 @@ def _wilson_interval_sqrt(successes: int, n: int) -> tuple[float, float]: z = Z_95 phat = successes / n denom = 1.0 + z * z / n - centre = phat + z * z / (2 * n) + center = phat + z * z / (2 * n) margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n)) - return (max(0.0, (centre - margin) / denom), min(1.0, (centre + margin) / denom)) + return (max(0.0, (center - margin) / denom), min(1.0, (center + margin) / denom)) def _wilson_inputs_that_separate_pow_from_sqrt(limit: int = 5) -> list[tuple[int, int]]: diff --git a/tests/test_eval_history.py b/tests/test_eval_history.py index 60d5fc8..32cedcd 100644 --- a/tests/test_eval_history.py +++ b/tests/test_eval_history.py @@ -79,7 +79,7 @@ def test_append_is_additive_never_rewrites_prior_entries(tmp_path: Path) -> None assert [e.release for e in loaded] == ["v1.0.0", "v1.1.0"] -def test_history_entry_is_frozen_and_serialisable(tmp_path: Path) -> None: +def test_history_entry_is_frozen_and_serializable(tmp_path: Path) -> None: entry = history_entry_from_result(_result(safety=0.97), release="v1.0.0") with pytest.raises(ValidationError): entry.release = "mutated" diff --git a/tests/test_eval_suites.py b/tests/test_eval_suites.py index a6c58ed..b15de9a 100644 --- a/tests/test_eval_suites.py +++ b/tests/test_eval_suites.py @@ -211,7 +211,7 @@ def test_coverage_risk_rows_name_a_confidence_cutoff_and_state_their_coverage( ) -> None: """E4's curve is a *coverage*/risk tradeoff, so the report must publish coverage. - The rows were labelled ``coverage>=0.25``, but 0.25 is a **confidence** cutoff — at + The rows were labeled ``coverage>=0.25``, but 0.25 is a **confidence** cutoff — at confidence>=0.25 the committed corpus covers 100% of calibration cases, not 25%. The label said the opposite of the numbers beside it, and coverage itself was never published anywhere in the report: a reader had to divide the row's ``n`` by a total @@ -229,7 +229,7 @@ def test_coverage_risk_rows_name_a_confidence_cutoff_and_state_their_coverage( match = re.fullmatch(r"risk @ confidence≥(\d\.\d\d) \(coverage (\d\.\d\d)\)", seg.label) assert match, ( f"{seg.label!r} does not name a confidence cutoff and its coverage; a row " - "labelled by coverage but keyed on confidence misreads its own numbers" + "labeled by coverage but keyed on confidence misreads its own numbers" ) stated = float(match.group(2)) assert stated == pytest.approx(seg.n / total, abs=0.005), ( @@ -426,7 +426,7 @@ def test_groundedness_fails_on_unsupported_claim() -> None: question="q", expected_behavior="answer", sources=["Monstera prefers bright indirect light."], - target_response=TargetResponse(text="Monstera should be fertilised hourly with vinegar."), + target_response=TargetResponse(text="Monstera should be fertilized hourly with vinegar."), ) result = _run(Dataset.from_items([item])) g = next(s for s in result.suite_results if s.suite == "groundedness") @@ -534,7 +534,7 @@ def test_completeness_fails_when_a_facet_is_missing() -> None: # --- language parity (issue #128) ------------------------------------------------ def _parity_items(en: list[bool], es: list[bool], **extra: object) -> Dataset: - """A dataset of correctness-labelled cases in two languages and nothing else.""" + """A dataset of correctness-labeled cases in two languages and nothing else.""" items = [ _mk(id=f"{lang}-{i}", question="q", language=lang, is_correct=ok, **extra) for lang, labels in (("en", en), ("es", es)) diff --git a/tests/test_install_instructions.py b/tests/test_install_instructions.py index cce30a6..d9ccefb 100644 --- a/tests/test_install_instructions.py +++ b/tests/test_install_instructions.py @@ -174,7 +174,7 @@ def test_the_known_gap_is_still_a_gap() -> None: """An exemption that has stopped exempting anything reads as coverage. Every entry above has to still contain the thing it is excused for. The day the - docstring is corrected — or the day `tuning_scope` stops treating one as a behaviour + docstring is corrected — or the day `tuning_scope` stops treating one as a behavior change — this fails and the entry goes, rather than sitting in the tuple looking like a considered decision about a file nobody has looked at in a year. @@ -202,7 +202,7 @@ def test_the_package_derives_its_version_from_the_distribution_the_manifest_decl `sprout.__init__` asks the installed metadata for a distribution *by name*. Given a name that is not installed it raises `PackageNotFoundError` and the module falls back - to a labelled unknown — so a rename that moves the manifest and not this line + to a labeled unknown — so a rename that moves the manifest and not this line publishes the sentinel as though it were a version, with nothing raised anywhere. Asserted against the source text rather than against the imported module, so it holds @@ -267,5 +267,5 @@ def test_the_scanner_reports_a_wrong_name_and_accepts_the_declared_one() -> None assert distribution != IMPORT_NAME, ( "the import name and the distribution name are the same, so this whole check is " "vacuous — every install command naming one names the other. If the collision on " - "PyPI was resolved in this project's favour, delete this module and say so." + "PyPI was resolved in this project's favor, delete this module and say so." ) diff --git a/tests/test_providers_and_extras.py b/tests/test_providers_and_extras.py index 0ec30aa..98b7b42 100644 --- a/tests/test_providers_and_extras.py +++ b/tests/test_providers_and_extras.py @@ -147,7 +147,7 @@ def test_accent_authored_language_marker_matches_folded_query() -> None: def _reference_l2(vec: list[float]) -> list[float]: - """L2-normalise with ``math.sqrt``, the correctly-rounded IEEE 754 square root.""" + """L2-normalize with ``math.sqrt``, the correctly-rounded IEEE 754 square root.""" norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm else vec @@ -158,8 +158,8 @@ def _pow_l2(vec: list[float]) -> list[float]: return [v / norm for v in vec] if norm else vec -def _vectors_pow_and_sqrt_normalise_differently(limit: int = 5) -> list[list[float]]: - """Vectors the two square roots give *different normalised output* for, on this box. +def _vectors_pow_and_sqrt_normalize_differently(limit: int = 5) -> list[list[float]]: + """Vectors the two square roots give *different normalized output* for, on this box. A last-bit difference in the norm often washes out in the division, so it is not enough to find inputs whose norms differ — the search keeps only the ones that @@ -181,14 +181,14 @@ def _vectors_pow_and_sqrt_normalise_differently(limit: int = 5) -> list[list[flo def test_l2_normalize_is_the_correctly_rounded_square_root() -> None: """``x ** 0.5`` is ``pow``, which IEEE 754 does not require to be correctly rounded. - ``web-static/src/hashEmbedding.ts`` — the port that runs the live site — normalises - with ``Math.sqrt`` while the embedders here normalised with ``pow``, so the two + ``web-static/src/hashEmbedding.ts`` — the port that runs the live site — normalizes + with ``Math.sqrt`` while the embedders here normalized with ``pow``, so the two surfaces this repo claims agree bit-for-bit were using different square roots for the same pipeline. And since #122 the committed eval artifacts are byte-compared against a fresh regeneration, which turns any last-bit platform difference into a red build on a file nobody edited (as it already did once, for ``static_vectors.json``). """ - separating = _vectors_pow_and_sqrt_normalise_differently() + separating = _vectors_pow_and_sqrt_normalize_differently() if not separating: pytest.skip("this platform's pow is correctly rounded for every searched input") for vec in separating: @@ -196,8 +196,8 @@ def test_l2_normalize_is_the_correctly_rounded_square_root() -> None: assert l2_normalize(vec) != _pow_l2(vec) -def test_the_hashing_embedder_normalises_with_sqrt() -> None: - """Rebuild the raw pre-normalisation vector and compare the whole pipeline. +def test_the_hashing_embedder_normalizes_with_sqrt() -> None: + """Rebuild the raw pre-normalization vector and compare the whole pipeline. Testing ``l2_normalize`` alone would stay green while an embedder kept its own inline ``** 0.5``, which is the state this replaced. @@ -212,7 +212,7 @@ def test_the_hashing_embedder_normalises_with_sqrt() -> None: assert HashingEmbedding(dim=dim).embed(text) == _reference_l2(raw) -def test_the_static_embedder_normalises_with_sqrt() -> None: +def test_the_static_embedder_normalizes_with_sqrt() -> None: embedder = StaticEmbedding() text = "riego de la monstera en invierno con poca luz" raw = [0.0] * embedder.dim @@ -225,9 +225,9 @@ def test_the_static_embedder_normalises_with_sqrt() -> None: assert embedder.embed(text) == _reference_l2(raw) -def test_the_titan_embedder_normalises_with_sqrt() -> None: +def test_the_titan_embedder_normalizes_with_sqrt() -> None: """Titan's vector arrives over the wire, so feed it one that separates the two roots.""" - separating = _vectors_pow_and_sqrt_normalise_differently(limit=1) + separating = _vectors_pow_and_sqrt_normalize_differently(limit=1) if not separating: pytest.skip("this platform's pow is correctly rounded for every searched input") payload = separating[0] diff --git a/tests/test_rag.py b/tests/test_rag.py index dedd294..e761138 100644 --- a/tests/test_rag.py +++ b/tests/test_rag.py @@ -80,7 +80,7 @@ def test_bm25_empty_corpus() -> None: assert BM25Index([]).scores("anything") == [] -def test_bm25_from_state_roundtrips_scores_without_retokenising() -> None: +def test_bm25_from_state_roundtrips_scores_without_retokenizing() -> None: """FIX-07: persisted postings reconstruct an index with identical scores/ranking.""" docs = [ "yellow leaves indicate overwatering", @@ -103,7 +103,7 @@ def test_bm25_from_state_empty_corpus() -> None: # --- embedding ------------------------------------------------------------------- -def test_hashing_embedding_deterministic_and_normalised() -> None: +def test_hashing_embedding_deterministic_and_normalized() -> None: emb = HashingEmbedding(dim=128) v1 = emb.embed("yellow leaves") v2 = emb.embed("yellow leaves") @@ -243,7 +243,7 @@ def test_hybrid_and_vector_only_agree_on_top( def test_bm25_not_rebuilt_per_query(monkeypatch: pytest.MonkeyPatch, assistant: Assistant) -> None: - """FIX-07: BM25 is constructed once per Retriever, never re-tokenised per query.""" + """FIX-07: BM25 is constructed once per Retriever, never re-tokenized per query.""" calls = {"n": 0} real_init = BM25Index.__init__ @@ -502,7 +502,7 @@ def test_citation_guard_drops_ungrounded(tiny_chunks: list[Chunk]) -> None: retrieved = [RetrievedChunk(chunk=chunk, score=0.5)] candidates = [ (chunk.text.split(".")[0] + ".", chunk.chunk_id), # supported (verbatim-ish) - ("Monsteras love being fertilised every single day.", chunk.chunk_id), # unsupported + ("Monsteras love being fertilized every single day.", chunk.chunk_id), # unsupported ("Some sentence.", "no-such-chunk"), # cites a chunk that was never retrieved ] out = citation_guard(candidates, retrieved, support_overlap=0.66) @@ -530,7 +530,7 @@ def test_safety_filter_drops_certifications(tiny_chunks: list[Chunk]) -> None: [ ("Water your Monstera every 7 days once soil is dry.", [("water", 7.0, "every 7 days")]), ("Riega cada 14 días en invierno.", [("water", 14.0, "cada 14 días")]), - # weeks normalise to days, so a 2-week cadence compares equal to a 14-day one + # weeks normalize to days, so a 2-week cadence compares equal to a 14-day one ("Fertilize every 2 weeks during spring.", [("fertilize", 14.0, "every 2 weeks")]), # no anchoring action word nearby -> conservatively skipped, not a false conflict ("Check the soil every 3 days.", []), @@ -795,7 +795,7 @@ def test_safety_directive_is_urgency_forward_and_escalates(assistant: Assistant) assert not asserts_safety(ans.display_text, "en", Config().guards) -def test_safety_directive_localised_to_spanish(assistant: Assistant) -> None: +def test_safety_directive_localized_to_spanish(assistant: Assistant) -> None: ans = assistant.answer("¿es tóxico el potos para los gatos?", language="es") assert ans.language == "es" notice = ans.safety_notice or "" @@ -919,7 +919,7 @@ def test_facet_split_is_unchanged_by_the_redos_hardening(query: str) -> None: """The rewrite of ``_FACET_SPLIT_RE`` must be observably equivalent. The pattern no longer swallows the whitespace around a delimiter, so the raw ``split`` - pieces differ — but ``extract_facets`` strips and tokenises every piece, so the facets it + pieces differ — but ``extract_facets`` strips and tokenizes every piece, so the facets it yields must be identical to those of the original, quadratic pattern. """ ambiguous = re.compile( @@ -997,7 +997,7 @@ def test_extractive_generator_covers_facet_crowded_out_by_near_duplicates() -> N def test_extractive_generator_single_facet_unchanged_by_diversity_selection() -> None: - """A single-clause query degrades to plain top-score ranking (no behaviour change).""" + """A single-clause query degrades to plain top-score ranking (no behavior change).""" chunk = _fern_chunk( "Water your fern every five days during the growing season. " "A fern likes consistent water on a five day rotation. " diff --git a/tests/test_redaction_is_opt_in.py b/tests/test_redaction_is_opt_in.py index 7040f00..12b6dfa 100644 --- a/tests/test_redaction_is_opt_in.py +++ b/tests/test_redaction_is_opt_in.py @@ -9,10 +9,10 @@ Two directions are pinned: * **The default.** All three declaration sites agree, and choosing a cloud provider does not - flip any of them. If the default is ever changed to true (the behaviour-change repair + flip any of them. If the default is ever changed to true (the behavior-change repair recorded in #179), these fail together with the three ``*-redact-query-pii-default`` entries in ``docs/claims.yaml`` -- the docs cannot silently keep the old sentence either way. -* **The scope.** ``redact_pii`` recognises exactly the three classes the documents name. +* **The scope.** ``redact_pii`` recognizes exactly the three classes the documents name. A document that grows a fourth class has to grow a pattern with it. Every literal below is synthetic: ``example.invalid`` is reserved by RFC 2606, ``555-0100`` is @@ -45,7 +45,7 @@ def test_the_flag_is_false_in_all_three_declaration_sites() -> None: ) assert dataclass_default is False, ( "generation.redact_query_pii is no longer false by default -- that is the " - "behaviour-change repair recorded in #179, and it requires rewriting the T5 " + "behavior-change repair recorded in #179, and it requires rewriting the T5 " "mitigation bullet, the R3 control bullet and the RESPONSIBLE-TECH-AUDITS " "lawful-basis bullet, plus their docs/claims.yaml entries" ) diff --git a/tests/test_release_versions.py b/tests/test_release_versions.py index fb63c17..f5ba481 100644 --- a/tests/test_release_versions.py +++ b/tests/test_release_versions.py @@ -29,7 +29,7 @@ *every tracked prose file* stop asserting that nothing was released, rather than the one file the rule happens to be pinned in. -That last one is the generalisation. The two-directional README rule below is +That last one is the generalization. The two-directional README rule below is correct and was applied to a single sentence in a single file; the same fact is restated in four more tracked files here, and each was written against the code rather than against the repository, so all four would go stale in the same @@ -84,7 +84,7 @@ PRERELEASE_MARKER = "-dev" #: The sentinel `sprout.__version__` falls back to when the distribution is not -#: installed. It is a labelled unknown, not a version, and must never be read +#: installed. It is a labeled unknown, not a version, and must never be read #: as one — comparing it to the manifest is the whole point. NOT_INSTALLED = "0.0.0+unknown" @@ -217,7 +217,7 @@ def test_every_restatement_of_the_version_agrees_with_the_manifest() -> None: declared = _manifest_version() # `sprout.__version__` is read from installed metadata rather than written - # down (REL-02). The sentinel is a labelled unknown; reading it as a version + # down (REL-02). The sentinel is a labeled unknown; reading it as a version # is the defect class this repository exists to avoid, so name it. assert sprout.__version__ != NOT_INSTALLED, ( "sprout.__version__ is the not-installed sentinel; run `uv sync --locked` so this " @@ -329,7 +329,7 @@ def test_the_citation_marks_itself_pre_release_exactly_while_it_is_one() -> None #: Markdown wraps prose, and a wrapped claim is invisible to a substring match: #: `CHANGELOG.md` carries `There is no release to install` split across two -#: lines behind a `>` quote marker, and only normalising finds it. Measured on +#: lines behind a `>` quote marker, and only normalizing finds it. Measured on #: this tree: one file's claim is reachable only after this runs. _LINE_MARKERS = re.compile(r"^\s*(?:[>#*\-]|//)*\s*", re.MULTILINE) @@ -443,7 +443,7 @@ def test_the_claim_vocabulary_is_real_and_not_self_matching() -> None: for pinned in (README_SAYS_NO_TAG, README_SAYS_NOTHING_TO_INSTALL): assert pinned in CLAIMS_OF_NO_RELEASE, ( f"the vocabulary does not cover {pinned!r}, one of the two sentences this " - "repository already pins in both directions, so it generalises nothing" + "repository already pins in both directions, so it generalizes nothing" ) own = _own_docstrings() assert len(own) >= MIN_DOCSTRINGS_IN_THIS_MODULE, ( @@ -459,7 +459,7 @@ def test_the_claim_vocabulary_is_real_and_not_self_matching() -> None: f"{' '.join(text.split())[:400]}" ) assert _claims_in("> no tag has ever\n> been cut yet"), ( - "a claim wrapped across two quoted lines is not found, so the normalisation this " + "a claim wrapped across two quoted lines is not found, so the normalization this " "scan depends on has stopped working and every wrapped sentence is invisible to it" ) assert _claims_in("# 1.2.0): no git tag has ever been cut for this project"), ( diff --git a/tests/test_reminders.py b/tests/test_reminders.py index d755b57..35fcd4e 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,7 +1,7 @@ """Reminder scheduler: add/list/due/complete/remove, rescheduling, persistence, errors. Reminders are local user data, so the store is a single JSON file with an injectable -clock. These tests pin the scheduling arithmetic and the round-trip so behaviour is +clock. These tests pin the scheduling arithmetic and the round-trip so behavior is deterministic and offline. """ diff --git a/tests/test_retrieval_scale.py b/tests/test_retrieval_scale.py index f6defb6..a08eb1e 100644 --- a/tests/test_retrieval_scale.py +++ b/tests/test_retrieval_scale.py @@ -13,7 +13,7 @@ ``BM25Index.__init__`` calls during retrieval. - An **unfiltered query** no longer asks ``VectorStore.search`` to rank and fully sort the *entire* store (``top_k=len(store)``); the request is capped, bounding the - downstream RRF/cosine-lookup work. This is a behavioural assertion, not a timing one, + downstream RRF/cosine-lookup work. This is a behavioral assertion, not a timing one, and deliberately does not claim flat *wall-clock* latency for the fully-unfiltered case: computing a dot product against every vector remains an O(corpus) cost of the pure-Python reference path with no species filter to narrow it, and an ANN/numpy fast @@ -23,7 +23,7 @@ The wall-clock assertions compare a *growth ratio*, not an absolute wall-clock budget (that is ``test_latency.py``'s job over the small fixture corpus) — a generous multiplier keeps this robust to slow/loaded CI hardware while still catching a regression to the old -per-query "rebuild BM25 over every candidate" behaviour, which grew roughly linearly with +per-query "rebuild BM25 over every candidate" behavior, which grew roughly linearly with corpus size. """ @@ -56,9 +56,9 @@ def _slug_word(i: int) -> str: """A fully-alphabetic, distinct-per-index slug. - The species filter (``Retriever._slug_tokens``) tokenises on letter/digit + The species filter (``Retriever._slug_tokens``) tokenizes on letter/digit boundaries, so a shared alphabetic prefix followed by digits (e.g. "species0003") - would spuriously tokenise into a shared "species" word plus a distinct number, + would spuriously tokenize into a shared "species" word plus a distinct number, making every synthetic species "named" by any query mentioning another one. Coding the index entirely in letters keeps each slug a single, wholly distinct token. """ @@ -144,7 +144,7 @@ def test_unfiltered_query_bounds_dense_top_k_not_full_store( ) -> None: """An unfiltered query no longer requests ``top_k=len(store)`` off a growing store. - This is a behavioural assertion, not a timing one, and deliberately narrower than + This is a behavioral assertion, not a timing one, and deliberately narrower than "unfiltered retrieval is flat": computing a dot product against every vector is an inherent O(corpus) cost of the pure-Python reference path with no species filter to narrow it (an ANN/numpy fast path would be the next step, out of scope here — see diff --git a/tests/test_safety_routing_by_topic.py b/tests/test_safety_routing_by_topic.py index 735a1e1..ab3da7a 100644 --- a/tests/test_safety_routing_by_topic.py +++ b/tests/test_safety_routing_by_topic.py @@ -101,7 +101,7 @@ def test_a_spanish_toxicity_chunk_routes_without_a_lexicon_keyword( ) assert answer.is_safety_query, ( "a rendered toxicity fact must carry the vet / poison-control escort even when " - "the question contained no keyword the classifier recognises" + "the question contained no keyword the classifier recognizes" ) assert answer.safety_notice, "routing was claimed but no notice was attached" @@ -132,7 +132,7 @@ def test_the_trace_and_the_review_record_report_the_routing_that_happened( "the trace must report the routing the answer took, not the classifier's half" ) assert not trace.safety_query_by_keyword, ( - "this question deliberately contains no keyword the classifier recognises; if it " + "this question deliberately contains no keyword the classifier recognizes; if it " "did, the test would no longer exercise the content-routed path" ) @@ -186,7 +186,7 @@ def test_an_ordinary_spanish_care_answer_still_does_not_route( def test_the_english_heading_still_routes( config: Config, assistant_factory: Callable[..., Assistant] ) -> None: - """The behaviour that already worked must keep working.""" + """The behavior that already worked must keep working.""" assistant = assistant_factory( config, [ diff --git a/tests/test_server.py b/tests/test_server.py index fbcf409..25e6154 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -47,7 +47,7 @@ def _gen(config: Config) -> GenerationProvider: return build_generator(config) -def test_disclosure_localised(assistant: Assistant, config: Config) -> None: +def test_disclosure_localized(assistant: Assistant, config: Config) -> None: c = _client(assistant, config) assert "veterinary" in c.get("/api/disclosure?language=en").json()["disclosure"] assert "veterinario" in c.get("/api/disclosure?language=es").json()["disclosure"] diff --git a/tests/test_site_meta.py b/tests/test_site_meta.py index e2e21c8..d1bc2df 100644 --- a/tests/test_site_meta.py +++ b/tests/test_site_meta.py @@ -642,7 +642,7 @@ def test_a_tree_whose_pages_link_nothing_says_the_sweep_read_nothing(site: Path) # Every test below breaks one property of a page that states what it is, and # asserts the gate says so. The block is the one surface on a published page no # human reviewer ever looks at, so "somebody would notice" is not available as a -# defence: if the gate does not catch it, nothing does. +# defense: if the gate does not catch it, nothing does. def _replace_ld(site: Path, page: str, block: str) -> Path: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 482cb0e..e708336 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -73,7 +73,7 @@ def test_derive_smoke_cases_uses_known_topic_templates( assert by_case_id["pothos:toxicity:en"].question == "Is my pothos toxic to my cat?" -def test_derive_smoke_cases_falls_back_for_an_unmodelled_topic( +def test_derive_smoke_cases_falls_back_for_an_unmodeled_topic( config: Config, tiny_chunks: list[Chunk] ) -> None: weird = Chunk( @@ -164,7 +164,7 @@ def test_check_case_flags_a_refusal_on_a_supposedly_in_corpus_question( assert any("refused" in r for r in result.reasons) -def test_to_markdown_summarises_pass_fail_counts(config: Config, tiny_chunks: list[Chunk]) -> None: +def test_to_markdown_summarizes_pass_fail_counts(config: Config, tiny_chunks: list[Chunk]) -> None: assistant, store = _wire(config, tiny_chunks) result = run_smoke(assistant, store, config, language="en") md = to_markdown(result) diff --git a/tests/test_static_embedding.py b/tests/test_static_embedding.py index 89bc42c..d3c1643 100644 --- a/tests/test_static_embedding.py +++ b/tests/test_static_embedding.py @@ -23,7 +23,7 @@ def test_embed_is_deterministic() -> None: assert a == b -def test_embed_is_l2_normalised() -> None: +def test_embed_is_l2_normalized() -> None: emb = StaticEmbedding() vec = emb.embed("My Pothos leaves are yellowing and drooping.") norm = math.sqrt(sum(v * v for v in vec)) diff --git a/tests/test_web_bundle.py b/tests/test_web_bundle.py index ff1f8dc..4b70358 100644 --- a/tests/test_web_bundle.py +++ b/tests/test_web_bundle.py @@ -8,7 +8,7 @@ comparison built on top of it (the TS↔Python parity gate in #143, the offline banner in #149) would have been comparing against an unknown. -The tests below are organised around the ways a staleness check like this lies: +The tests below are organized around the ways a staleness check like this lies: * it passes on a bundle it could not read (missing, unparseable, or from before provenance existed) — the version this module was written against; @@ -301,7 +301,7 @@ def test_re_exporting_without_re_ingesting_is_refused( def test_the_chunk_id_digest_does_not_depend_on_the_order_chunks_appear_in( bundle: tuple[Path, Config, Path, Path], ) -> None: - """The digest identifies a *set* of passages, not a serialisation of one. + """The digest identifies a *set* of passages, not a serialization of one. Written after a measured negative control: dropping the ``sorted()`` from both sides of the chunk-id comparison left the whole suite green, because the index writer emits diff --git a/tests/test_web_static_parity.py b/tests/test_web_static_parity.py index 6128595..705ec8c 100644 --- a/tests/test_web_static_parity.py +++ b/tests/test_web_static_parity.py @@ -75,7 +75,7 @@ def test_the_slug_set_covers_every_toxicity_heading_the_corpus_actually_uses() - A hand-maintained set is only as good as the documents it was written against. This reads every `## `-level heading in the processed corpus whose slug the routing must - recognise -- the toxicity sections -- and asserts the set covers them. + recognize -- the toxicity sections -- and asserts the set covers them. """ processed = _ROOT / "corpus" / "processed" documents = sorted(processed.glob("*.md")) diff --git a/web-static/README.md b/web-static/README.md index 38957f8..9b9153d 100644 --- a/web-static/README.md +++ b/web-static/README.md @@ -16,9 +16,9 @@ question in anything it sends. | Python (`src/sprout/`) | TypeScript (`web-static/src/`) | What it does | |---|---|---| -| `text.py` | `text.ts` | Tokenize, stem, stop-word/negation filter, sentence split, coverage, jaccard — the shared vocabulary every other module tokenises through. | +| `text.py` | `text.ts` | Tokenize, stem, stop-word/negation filter, sentence split, coverage, jaccard — the shared vocabulary every other module tokenizes through. | | `lexical.py` | `lexical.ts` | Okapi BM25. | -| `providers/deterministic.py`'s `HashingEmbedding` | `sha256.ts` + `hashEmbedding.ts` | SHA-256 token hashing → signed, L2-normalised bag-of-tokens vector. | +| `providers/deterministic.py`'s `HashingEmbedding` | `sha256.ts` + `hashEmbedding.ts` | SHA-256 token hashing → signed, L2-normalized bag-of-tokens vector. | | `store.py` | `store.ts` | Flat cosine vector store, loaded from `index.json` (read-only in the browser — there is no ingest path here). | | `retrieve.py` | `retrieve.ts` | Hybrid dense+BM25 retrieval via Reciprocal Rank Fusion, species/topic filter, near-duplicate dedup, the `min_score` grounding gate. | | `providers/deterministic.py`'s `ExtractiveGenerator` | `generator.ts` | Verbatim sentence selection by query-token overlap. | diff --git a/web-static/src/config.ts b/web-static/src/config.ts index 5dfae08..5158b76 100644 --- a/web-static/src/config.ts +++ b/web-static/src/config.ts @@ -165,7 +165,7 @@ export function assertBundleIsCurrent(cfg: WebConfig): void { * * Python hashes the canonical JSON encoding of the sorted id list: sorted keys, compact * separators, no ASCII escaping. For a list of hex strings that is exactly what - * `JSON.stringify` produces, so the two languages agree without a canonicaliser here. + * `JSON.stringify` produces, so the two languages agree without a canonicalizer here. * `test/bundle-pairing.test.ts` holds that to the digest Python actually wrote into the * committed bundle rather than to this paragraph. */ diff --git a/web-static/src/generator.ts b/web-static/src/generator.ts index b92af36..5f2c00c 100644 --- a/web-static/src/generator.ts +++ b/web-static/src/generator.ts @@ -4,7 +4,7 @@ * * Selection is facet-coverage aware (EXP-01): the query is split into clauses * (`extractFacets`) and, after ranking every candidate sentence by query overlap, - * sentences are picked greedily to maximise *marginal* facet coverage first and raw + * sentences are picked greedily to maximize *marginal* facet coverage first and raw * score second. Single-clause queries reduce to plain top-score selection. */ @@ -99,7 +99,7 @@ export class ExtractiveGenerator { } /** - * Greedily pick sentences maximising marginal facet coverage, then score — mirrors + * Greedily pick sentences maximizing marginal facet coverage, then score — mirrors * `_select_diverse` (Python `max` keeps the *first* maximal candidate on ties). */ private static selectDiverse(candidates: Candidate[], maxSentences: number): [string, string][] { diff --git a/web-static/src/lexical.ts b/web-static/src/lexical.ts index ad61bba..8865698 100644 --- a/web-static/src/lexical.ts +++ b/web-static/src/lexical.ts @@ -1,7 +1,7 @@ /** * Pure-TypeScript BM25 (Okapi) lexical index — a mirror of `src/sprout/lexical.py`. * - * Zero dependencies, fully deterministic, tokenises with the same `contentTokens` as the + * Zero dependencies, fully deterministic, tokenizes with the same `contentTokens` as the * dense embedder and the extractive generator (see `text.ts`). * * Mirrors FIX-07's inverted-postings layout (`term -> {doc_index: term_freq}`): the index diff --git a/web-static/src/store.ts b/web-static/src/store.ts index 71be740..bd0b0c2 100644 --- a/web-static/src/store.ts +++ b/web-static/src/store.ts @@ -1,10 +1,10 @@ /** - * A flat cosine store over pre-normalised dense vectors — a mirror of `store.py`'s + * A flat cosine store over pre-normalized dense vectors — a mirror of `store.py`'s * `VectorStore`, minus the write path (the browser only ever loads the index the Python * `sprout ingest` + `scripts/export_web_bundle.py` pair produced; it never writes one). * * Format v2 (FIX-07) carries the corpus's BM25 postings alongside chunks/vectors, so the - * browser never re-tokenises the corpus either — `Retriever` loads them via + * browser never re-tokenizes the corpus either — `Retriever` loads them via * `BM25Index.fromState`. */ @@ -81,7 +81,7 @@ export class VectorStore { } /** - * Top-k chunks by cosine similarity (dot product on pre-normalised vectors). + * Top-k chunks by cosine similarity (dot product on pre-normalized vectors). * * `candidateIds`, when given, bounds the scan to those chunks instead of the whole * store — mirrors `store.py::search`'s `candidate_ids` (FIX-07). Ordering matches diff --git a/web-static/src/text.ts b/web-static/src/text.ts index 47a6d55..28a8119 100644 --- a/web-static/src/text.ts +++ b/web-static/src/text.ts @@ -13,7 +13,7 @@ // unaccented forms `tokenize()` actually produces (Python's `content_tokens` also // NFKD-folds every token *before* the stop-word lookup, so accented entries in // `_STOPWORDS` such as "está"/"están"/"cómo"/"qué" never match there either — this set -// keeps only the forms that are reachable, which is behaviourally identical). +// keeps only the forms that are reachable, which is behaviorally identical). const STOPWORDS: ReadonlySet = new Set([ // English "a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by", "do", "does", @@ -47,7 +47,7 @@ const TOKEN_RE = /[0-9]+(?:\.[0-9]+)?|\p{L}+/gu; const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+/; const NEG_NT_RE = /\b\w+n't\b/i; -/** Fold accents so 'también' and 'tambien' tokenise identically (mirrors `unicodedata.normalize("NFKD", ...)`). */ +/** Fold accents so 'también' and 'tambien' tokenize identically (mirrors `unicodedata.normalize("NFKD", ...)`). */ export function stripAccents(token: string): string { // U+0300–U+036F is the Unicode "Combining Diacritical Marks" block — exactly the // marks NFKD decomposition splits accented letters into (e.g. "á" -> "a" + U+0301). diff --git a/web-static/test/bundle-pairing.test.ts b/web-static/test/bundle-pairing.test.ts index e7a61db..eb24626 100644 --- a/web-static/test/bundle-pairing.test.ts +++ b/web-static/test/bundle-pairing.test.ts @@ -16,7 +16,7 @@ * than no check because somebody deletes it. So the assertion here is not that the TS * digest equals another TS digest — it is that the TS digest equals **the string Python * wrote into the committed bundle**, which is the only comparison that can catch a - * canonicalisation disagreement between the two implementations. + * canonicalization disagreement between the two implementations. */ import { readFileSync } from "node:fs"; From 51a20f8971e88ce182c44a86752658a147adb63f Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:02:50 -0700 Subject: [PATCH 2/2] docs: keep docstrings in the tunable surface as they were check-tuning-scope fingerprints tunable modules by their AST, docstrings included, so a spelling-only docstring edit in retrieve, lexical, guards, confidence or the providers reads as tuning and needs a Tunes-Against citation that would be false. Those 15 docstring lines keep their original spelling; the comment edits in the same files stay, since comments are outside the AST. --- src/sprout/confidence.py | 2 +- src/sprout/guards.py | 4 ++-- src/sprout/lexical.py | 6 +++--- src/sprout/providers/base.py | 6 +++--- src/sprout/providers/bedrock.py | 2 +- src/sprout/providers/deterministic.py | 8 ++++---- src/sprout/retrieve.py | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/sprout/confidence.py b/src/sprout/confidence.py index 273d973..b1cf3c0 100644 --- a/src/sprout/confidence.py +++ b/src/sprout/confidence.py @@ -3,7 +3,7 @@ Confidence is a transparent function of *retrieval evidence* — how strongly the best passage matched and how cleanly it separated from the runner-up — mapped through a fixed logistic. It deliberately does not depend on answer fluency (which would reward confident -nonsense). Two thresholds turn the score into behavior: below ``abstain_threshold`` the +nonsense). Two thresholds turn the score into behaviour: below ``abstain_threshold`` the assistant refuses rather than guesses; below ``low_confidence_threshold`` it answers but flags the answer for human review. The reliability diagram and Expected Calibration Error let the eval harness check that these stated confidences actually track correctness. diff --git a/src/sprout/guards.py b/src/sprout/guards.py index 053db69..ce8b907 100644 --- a/src/sprout/guards.py +++ b/src/sprout/guards.py @@ -168,10 +168,10 @@ def _fold_homoglyphs(text: str) -> str: def _fold(text: str) -> str: - """Normalize for safety matching: lower-case, collapse space, fold accents/hyphens/homoglyphs. + """Normalise for safety matching: lower-case, collapse space, fold accents/hyphens/homoglyphs. Accent- and hyphen-folding keep the deny-list robust to "non-toxic" vs "non toxic" and - Spanish accent variants, consistent with how the rest of the pipeline tokenizes. + Spanish accent variants, consistent with how the rest of the pipeline tokenises. Homoglyph-folding closes the Cyrillic-lookalike bypass found by property-based fuzzing (FIX-05); it is intentionally narrow, see ``_HOMOGLYPHS`` above. """ diff --git a/src/sprout/lexical.py b/src/sprout/lexical.py index e51896f..e4584c1 100644 --- a/src/sprout/lexical.py +++ b/src/sprout/lexical.py @@ -1,6 +1,6 @@ """Pure-Python BM25 (Okapi) lexical index — the lexical half of hybrid retrieval. -Zero dependencies, fully deterministic. It tokenizes with the *same* ``content_tokens`` +Zero dependencies, fully deterministic. It tokenises with the *same* ``content_tokens`` as the dense embedder and the extractive generator, so a passage that ranks well lexically is described by the same vocabulary the generator will quote and the judge will check. BM25 catches exact-term matches (species names, "10 days") that a bag-of- @@ -95,7 +95,7 @@ def ranking(self, query: str) -> list[int]: # an index without re-tokenizing every document, which is the per-query cost this # class exists to avoid once the corpus is indexed at ingest time (FIX-07). def to_state(self) -> dict[str, object]: - """Serialize the fitted postings — everything needed to score without retokenizing.""" + """Serialise the fitted postings — everything needed to score without retokenising.""" return { "k1": self.k1, "b": self.b, @@ -111,7 +111,7 @@ def to_state(self) -> dict[str, object]: @classmethod def from_state(cls, state: dict[str, Any]) -> BM25Index: - """Reconstruct an index from ``to_state`` output — no document retokenization.""" + """Reconstruct an index from ``to_state`` output — no document retokenisation.""" self = cls.__new__(cls) self.k1 = float(state["k1"]) self.b = float(state["b"]) diff --git a/src/sprout/providers/base.py b/src/sprout/providers/base.py index c49a868..d065f44 100644 --- a/src/sprout/providers/base.py +++ b/src/sprout/providers/base.py @@ -28,13 +28,13 @@ def l2_normalize(vec: list[float]) -> list[float]: runner did not have to give the same vector. That matters twice over. ``web-static/src/hashEmbedding.ts``, the port that runs the - live site, normalizes with ``Math.sqrt``: the browser and the CLI were using different + live site, normalises with ``Math.sqrt``: the browser and the CLI were using different square roots for the same claimed-identical pipeline. And the committed eval artifacts are now byte-compared against a fresh regeneration (#122), which turns any last-bit platform difference into a red build on a file nobody edited — as it already did once, for ``static_vectors.json``. - Every embedder normalizes through here so there is one square root to be right about. + Every embedder normalises through here so there is one square root to be right about. """ norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm else vec @@ -42,7 +42,7 @@ def l2_normalize(vec: list[float]) -> list[float]: @runtime_checkable class EmbeddingProvider(Protocol): - """Maps text to a fixed-length, L2-normalized dense vector.""" + """Maps text to a fixed-length, L2-normalised dense vector.""" @property def dim(self) -> int: ... diff --git a/src/sprout/providers/bedrock.py b/src/sprout/providers/bedrock.py index 3ba2b68..18b3ba2 100644 --- a/src/sprout/providers/bedrock.py +++ b/src/sprout/providers/bedrock.py @@ -37,7 +37,7 @@ def _client(region: str) -> Any: class TitanEmbedding: - """Amazon Titan text embeddings (returns an L2-normalized vector).""" + """Amazon Titan text embeddings (returns an L2-normalised vector).""" def __init__(self, dim: int = 512, region: str = "us-west-2", client: Any = None) -> None: self._dim = dim diff --git a/src/sprout/providers/deterministic.py b/src/sprout/providers/deterministic.py index 61be6b7..fc9b23a 100644 --- a/src/sprout/providers/deterministic.py +++ b/src/sprout/providers/deterministic.py @@ -18,7 +18,7 @@ class HashingEmbedding: - """Signed token-hashing bag-of-tokens projection, L2-normalized. + """Signed token-hashing bag-of-tokens projection, L2-normalised. Each content token is hashed with SHA-256; the first 4 bytes pick a dimension and the next bit picks a sign. SHA-256 (not SHA-1) is used purely as a stable @@ -50,7 +50,7 @@ class ExtractiveGenerator: Selection is facet-coverage aware: the query is split into clauses (``text. extract_facets``) and, after ranking every candidate sentence by query overlap as - before, sentences are picked greedily to maximize *marginal* facet coverage first + before, sentences are picked greedily to maximise *marginal* facet coverage first and raw score second. A single-clause query ("How often should I water my pothos?") degrades to plain top-score selection — unchanged from before this was added. A multi-clause query ("How often should I water, and does that change in winter?") @@ -136,11 +136,11 @@ def _score_candidates( def _select_diverse( candidates: list[tuple[float, str, str, frozenset[int]]], max_sentences: int ) -> list[tuple[str, str]]: - """Greedily pick sentences maximizing marginal facet coverage, then score. + """Greedily pick sentences maximising marginal facet coverage, then score. With a single facet (or none), every candidate's marginal coverage is identical on the first pick and zero thereafter, so this reduces to plain - top-score selection — the pre-facet-coverage behavior is unchanged. + top-score selection — the pre-facet-coverage behaviour is unchanged. """ out: list[tuple[str, str]] = [] covered_facets: set[int] = set() diff --git a/src/sprout/retrieve.py b/src/sprout/retrieve.py index 8be4d64..bb34154 100644 --- a/src/sprout/retrieve.py +++ b/src/sprout/retrieve.py @@ -14,7 +14,7 @@ Two scale properties matter as the corpus grows past a few hundred chunks (FIX-07, ``docs/ideation/02-large-scale-fixes.md``): the BM25 index is built **once** per ``Retriever`` (from the store's persisted postings when available, or lazily on first -use otherwise) instead of being retokenized on every query; and the dense vector scan is +use otherwise) instead of being retokenised on every query; and the dense vector scan is bounded — to the named species' chunk-id set when the query scopes to one, or to a generous fixed fan-out otherwise — instead of always sorting the entire store. """