feat(retail): graded, density-gated vulgarity forces asiaton (ADR-0039) - #60
Merged
Conversation
A vulgar pile-up ("Paskapillupersepornolehtipaviaani") was stored category=muu
(rated catch-all), severity=high — Poro recognized the offensiveness (theme=
offensive_language) but filed it under the rated catch-all, and the only
deterministic conduct override today is the rasismi ethnic-slur lexicon, which
this word doesn't hit. The demoted-category suppression (ADR-0032/0033/0038)
can't help content that was never demoted.
Add a deterministic, DENSITY-gated graded vulgarity scorer that forces the
demoted conduct category (asiaton), a sibling to the alert (ADR-0027) and
category-keyword (ADR-0036) layers:
- domains/retail/vulgarity-lexicon.json — tiered Finnish profanity stems (mild/
strong) + thresholds; optional domain data, validated at boot (a demoteToCategory
that isn't a declared DEMOTED category fails the boot).
- VulgarityScorer (Core): level 0-3. Demote (level 3) iff the vulgar-character
share >= demoteRatio AND >= demoteMinDistinctStems DISTINCT stems. Both gates
matter: distinct-count (never raw occurrences) keeps a repeated single swear
rated; the ratio keeps a furious-but-substantive complaint (mostly real words)
rated while a nonsense pile-up (mostly stems) is demoted.
- CategoryOverrideResolver hook: after the rasismi alert override and an existing
demoted choice, before the product category-keyword override. Ethnic slurs stay
in the alert lexicon (single hit -> rasismi); this scorer never touches them.
- Wired into ingest, restructure, and the /interpret preview so the forced
category is identical everywhere.
"Paskapillupersepornolehtipaviaani" (3 distinct, ~0.45 share) -> asiaton;
"Möivät paskaa." (1 distinct, ~0.31 share) and a substantive complaint carrying
three swears (low share) stay rated.
Thresholds are empirical and config-tunable — the ADR states plainly they must be
measured on real examples and will mis-fire outside the fixture until tuned. The
visible per-item vulgarity recognition tag for rated level 1-2 items is a
deferred follow-up (Phase 2), noted in the ADR.
Tests: VulgarityScorer (incl. the furious-but-substantive precision case),
VulgarityLexiconSet boot validation, and CategoryOverrideResolver precedence
(racism > vulgarity > product-keyword). Full solution green.
| Descriptor = BuildDescriptor(root, o.Active, domainJson); | ||
| AlertKeywordsPath = Path.Combine(dir, "alert-keywords.json"); | ||
| CategoryKeywordsPath = Path.Combine(dir, "category-keywords.json"); | ||
| VulgarityLexiconPath = Path.Combine(dir, "vulgarity-lexicon.json"); |
| /// resolve its path, so a module move breaks exactly one helper.</summary> | ||
| public static Api.Structuring.VulgarityLexiconSet RetailVulgarity() => | ||
| Api.Structuring.VulgarityLexiconSet.LoadFrom( | ||
| Path.Combine(RepoRoot(), "domains", "retail", "vulgarity-lexicon.json"), |
| var dir = Path.Combine(RepoRoot(), "domains", "retail"); | ||
| AlertKeywordsPath = Path.Combine(dir, "alert-keywords.json"); | ||
| CategoryKeywordsPath = Path.Combine(dir, "category-keywords.json"); | ||
| VulgarityLexiconPath = Path.Combine(dir, "vulgarity-lexicon.json"); |
| public void MissingFile_ReturnsEmpty_DemotesNothing() | ||
| { | ||
| var set = VulgarityLexiconSet.LoadFrom( | ||
| Path.Combine(Path.GetTempPath(), $"no-such-{Guid.NewGuid():N}.json"), Declared, Demoted); |
| public void DemoteToCategory_NotADemotedCategory_FailsBoot() | ||
| { | ||
| // 'muu' is a declared category but NOT demoted — forcing it would re-rate real feedback. | ||
| var path = Path.Combine(Path.GetTempPath(), $"vulg-{Guid.NewGuid():N}.json"); |
| [Fact] | ||
| public void DemoteToCategory_Undeclared_FailsBoot() | ||
| { | ||
| var path = Path.Combine(Path.GetTempPath(), $"vulg-{Guid.NewGuid():N}.json"); |
…edup, threshold validation) Adversarial review of the diff surfaced three owner-config-path issues (none fire with the committed lexicon, but the lexicon is owner-authored, so they would): - CONFIRMED crash: the match loop advanced the cursor by the STEM length under culture-aware (InvariantCulture) matching, which can match a span SHORTER than the stem (an NFD-authored Finnish stem — ä/ö have NFC/NFD ambiguity — against NFC text), overshooting the end and throwing ArgumentOutOfRangeException on /interpret + ingest. Now advances by the ACTUAL matched length via the span IndexOf(out matchLength) overload; can never over-/under-run the buffer. - Overlap/dedup: matchedChars summed n*stem.Length, so overlapping/substring stems double-counted and the ratio could exceed 1; and a stem listed in both tiers inflated the distinct-count, defeating the gate that keeps a repeated single swear rated. Now a coverage MASK bounds the ratio at <=1 and stems are de-duplicated across tiers (counted once). - Loader: demoteRatio / demoteMinDistinctStems silently fell back to defaults on a present-but-wrong-typed value (a quoted "0.6" or null), unlike the strict demoteToCategory check. Now a present non-number fails the boot. Also corrects an ADR figure (~0.31 -> ~0.38 share for "Möivät paskaa.", noting the distinct gate — not the ratio — is what keeps it rated). Adds regression tests: NFD-authored stem matches NFC text without crashing; duplicate stem across tiers stays rated; wrong-typed threshold fails the boot. Full solution green (Api 166).
Comment on lines
+69
to
+70
| foreach (var c in covered) | ||
| if (c) matchedChars++; |
Comment on lines
+88
to
+90
| foreach (var s in lex.MildStems) | ||
| if (!string.IsNullOrEmpty(s) && seen.Add(s)) | ||
| yield return (s, false); |
Comment on lines
+91
to
+93
| foreach (var s in lex.StrongStems) | ||
| if (!string.IsNullOrEmpty(s) && seen.Add(s)) | ||
| yield return (s, true); |
| { | ||
| // A quoted "0.6" was meant to tune the gate — silently falling back to the default | ||
| // would run the gate looser than configured, so it must fail the boot (not fall back). | ||
| var path = Path.Combine(Path.GetTempPath(), $"vulg-{Guid.NewGuid():N}.json"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the graded vulgarity detection you approved. Fixes the case where
Paskapillupersepornolehtipaviaanisat rated (category=muu,severity=high) instead of in moderation.Why the earlier fixes didn't cover it
That pile-up was stored
category=muu(rated catch-all),theme=offensive_language,severity=high,alerts=[]. Poro recognized the offensiveness but filed it under the rated catch-all, and the only deterministic conduct override today is the rasismi ethnic-slur lexicon (ADR-0027) — this word isn't ethnic, so nothing fired. The demoted-category suppression (ADR-0032/0033/0038) can't touch content that was never demoted.What this adds
A deterministic, density-gated graded vulgarity scorer that forces the demoted conduct category (
asiaton) — a sibling to the alert and category-keyword layers:domains/retail/vulgarity-lexicon.json— tiered Finnish profanity stems (mild/strong) + thresholds. Optional domain data, validated at boot (ademoteToCategorythat isn't a declared demoted category fails the boot).VulgarityScorer(Core) — level 0–3. Demote (level 3) iff vulgar-character share ≥demoteRatioAND ≥demoteMinDistinctStemsdistinct stems. Both gates matter:CategoryOverrideResolverhook — after the rasismi alert override and an already-demoted choice, before the product category-keyword override: racism → dense vulgarity → product hints → model. Ethnic slurs stay in the alert lexicon (single hit →rasismi); this scorer never touches them (per your "extreme = racism")./interpretpreview so the forced category is identical everywhere.Result:
Paskapillupersepornolehtipaviaani(3 distinct, ~0.45 share) →asiaton(unrated, count-only in the moderation view);Möivät paskaa.(1 distinct, ~0.31 share) and a substantive complaint with three swears (low share) stay rated.Honest about the limits
Thresholds are empirical and config-tunable — ADR-0039 states plainly they must be measured on your real examples and will mis-fire outside the fixture until tuned. No invented Finnish corpus is used (the eval cases are yours). The stem list is a starter you approved; edit
vulgarity-lexicon.jsonfreely.Deferred (Phase 2)
The visible per-item
⚑ kiroilurecognition tag for rated level 1–2 items ("flag cursing without demoting") — it needs a structure/schema field + view work and stays out of the operational alert channel (ADR-0033). The graded scorer lands now; only its lower-tier presentation is deferred. Noted in the ADR.Tests
VulgarityScorerTests— incl. the furious-but-substantive precision case (3 swears in real text → stays rated), the pile-up → demote, repeated single swear → rated, content-free wall → demote.VulgarityLexiconSetTests— boot validation (non-demoted / undeclareddemoteToCategoryfails; missing file → empty).CategoryOverrideResolverTests— precedence (racism > vulgarity > product-keyword; lone swear falls through).AI-first docs
Yes — new ADR-0039, indexed in
docs/decisions/README.md; new domain config file documented inline.