Skip to content

feat(retail): graded, density-gated vulgarity forces asiaton (ADR-0039) - #60

Merged
MikkoNumminen merged 2 commits into
masterfrom
feat/vulgarity-density-asiaton
Jul 15, 2026
Merged

feat(retail): graded, density-gated vulgarity forces asiaton (ADR-0039)#60
MikkoNumminen merged 2 commits into
masterfrom
feat/vulgarity-density-asiaton

Conversation

@MikkoNumminen

Copy link
Copy Markdown
Owner

Implements the graded vulgarity detection you approved. Fixes the case where Paskapillupersepornolehtipaviaani sat 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 (a demoteToCategory that isn't a declared demoted category fails the boot).
  • VulgarityScorer (Core) — level 0–3. Demote (level 3) iff vulgar-character share ≥ demoteRatio ANDdemoteMinDistinctStems distinct stems. Both gates matter:
    • distinct-count (never raw occurrences) → a repeated single swear stays rated (your "singletons shouldn't flag");
    • ratio → a furious-but-substantive complaint (mostly real words, low share) stays rated, while a nonsense pile-up (mostly stems) is demoted. Neither gate alone separates angry real feedback from vulgar nonsense.
  • CategoryOverrideResolver hook — 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").
  • Wired into ingest, restructure, and the /interpret preview 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.json freely.

Deferred (Phase 2)

The visible per-item ⚑ kiroilu recognition 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 / undeclared demoteToCategory fails; missing file → empty).
  • CategoryOverrideResolverTests — precedence (racism > vulgarity > product-keyword; lone swear falls through).
  • Full solution: Api 163 · Llm 79 · Generator 25 · 0 failed.

AI-first docs

Yes — new ADR-0039, indexed in docs/decisions/README.md; new domain config file documented inline.

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");
@MikkoNumminen
MikkoNumminen merged commit 8f008fe into master Jul 15, 2026
3 checks passed
@MikkoNumminen
MikkoNumminen deleted the feat/vulgarity-density-asiaton branch July 15, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants