This document describes the VerbaGuard moderation pipeline, matching behavior, extension points, and result format. It is the behavioral contract for the library.
For project principles and API boundaries, see FOUNDATION.md.
Every analysis run follows the same global stages. Language-specific behavior is injected through LanguageProfile.
Raw text
→ Unicode normalize
→ Language-specific normalize
→ Leetspeak normalize
→ Repeated letter collapse
→ Matcher
→ Deduplicate overlapping matches
→ Score
→ AnalysisResult
The pipeline is global. Profiles provide:
code()dictionary()normalizers()
These symbols are intended for downstream use. Breaking changes follow semantic versioning and appear in CHANGELOG.md.
| Symbol | Namespace |
|---|---|
VerbaGuard |
VerbaGuard\ |
AnalysisResult |
VerbaGuard\ |
ProfanityMatch |
VerbaGuard\ |
Severity |
VerbaGuard\ |
LanguageProfile |
VerbaGuard\Contracts\ |
Dictionary |
VerbaGuard\Dictionary\ |
Entry |
VerbaGuard\Dictionary\ |
TurkishProfile |
VerbaGuard\Language\ |
Normalizer |
VerbaGuard\Normalizer\ (extension interface) |
These are implementation details. They are not covered by semver guarantees and may change without notice.
| Symbol | Namespace |
|---|---|
Pipeline |
VerbaGuard\Pipeline\ |
Matcher |
VerbaGuard\Pipeline\ |
TextSegments / SegmentRun |
VerbaGuard\Pipeline\ |
Scorer |
VerbaGuard\Pipeline\ |
NormalizationPipeline |
VerbaGuard\Pipeline\ |
| Concrete normalizers | VerbaGuard\Normalizer\ |
Do not depend on internal classes. Extend the library through LanguageProfile, Dictionary, and Normalizer only.
interface LanguageProfile
{
public function code(): string;
public function dictionary(): Dictionary;
public function normalizers(): array;
}code()returns an ISO-style language identifier such astr.dictionary()returns aDictionaryinstance.normalizers()returns an ordered list of profile-specificNormalizerimplementations applied after Unicode normalization and before global leetspeak/repeated-letter stages.
Dictionaries are plain PHP arrays loaded from files such as data/tr.php.
Each author row contains only user-written canonical fields:
| Field | Description |
|---|---|
term |
Canonical dictionary term |
category |
Semantic category (see Supported categories) |
severity |
One of clean, low, medium, high |
Do not include normalized in author rows. It is derived at dictionary build time.
Example author row:
[
'term' => 'amk',
'category' => 'profanity',
'severity' => 'medium',
]Use Dictionary::fromRows() with a normalizeKey callable. The callable must apply the same normalization chain the matcher uses at runtime (typically the profile's NormalizationPipeline).
Dictionary::fromRows(
rows: $rows,
normalizeKey: fn (string $term): string => $normalization->normalize($term),
);At build time, each term is passed through normalizeKey to produce the derived normalized lookup key stored on Entry.
| Field | Source | Description |
|---|---|---|
term |
Author | Canonical dictionary term |
category |
Author | Semantic category |
severity |
Author | Severity level |
normalized |
Derived | Build-time normalized form used for matching |
Dictionary::fromArray()removed — useDictionary::fromRows()instead.- Author dictionary rows no longer accept a
normalizedfield.
The category field is author-defined metadata carried on each dictionary entry. VerbaGuard v0.4 recognizes four supported categories for Turkish expansion:
| Category | Description |
|---|---|
profanity |
General profanity and vulgar slang |
insult |
Personal degradation or attack on worth/competence |
sexual |
Explicit sexual slang or sexual profanity |
abbreviation |
High-confidence abbreviation of profanity or insult |
Category definitions, severity guidance, false-positive risk notes, and expansion rules are documented in docs/dictionary-expansion-policy.md.
Out of scope for v0.4: the slur category. Slur moderation requires a separate ethical, cultural, and review policy and is not a supported category in current expansion work.
A standalone taxonomy reference document is intentionally deferred until the Turkish dictionary reaches approximately 100 curated entries.
Normalization runs per token or per spelled chain before dictionary lookup. Order is fixed.
Applies Unicode NFC normalization when the intl extension is available. Without intl, input passes through unchanged (see Known limitations).
Provided by the active LanguageProfile.
Turkish profile rules:
- lowercase UTF-8
ç -> cğ -> gı -> iİ -> iö -> oş -> sü -> u
Global map:
| Input | Output |
|---|---|
0 |
o |
1 |
i |
! |
i |
3 |
e |
4 |
a |
@ |
a |
5 |
s |
$ |
s |
7 |
t |
Three or more identical consecutive characters collapse to one. The rule is deterministic and applies to the full normalized string.
The matcher is deterministic and uses exact equality only. There is no substring search, compact matching, or approximate offset mapping.
Correctness policy: false positives are worse than false negatives.
- Tokenize the original text with
/[\p{L}\p{N}@$!]+/u(letter, digit, and leetspeak symbol runs). - Normalize each token through the full normalization pipeline.
- Look up the normalized form in the dictionary with exact key equality.
- On match, record a
ProfanityMatchwith byte-accuratestartandlengthfrom the original token span.
Examples:
| Input | Match | Reason |
|---|---|---|
amk |
amk |
Exact token |
4mk |
amk |
Leetspeak normalization |
bu mal bir şey |
mal |
Standalone insult token |
malzeme |
— | No exact token mal |
For obfuscation with separators between letters (e.g. s.i.k.t.i.r, s i k t i r):
- Segment the original text into alternating letter runs and separator runs (
TextSegments). - Build spelled chains: consecutive single-letter runs, with flush rules when a multi-character letter run appears.
- Concatenate the letters in each chain, normalize the full chain, and look up with exact dictionary equality.
- On match, span covers from the first chain run's byte start through the last chain run's byte end in the original text.
No sub-chain enumeration. Only the full spelled chain is tested. n o r m a l does not match mal because the normalized chain is normal, not mal.
Examples:
| Input | Match | Reason |
|---|---|---|
s.i.k.t.i.r |
siktir |
Full chain after normalization |
a m k |
amk |
Full chain |
n o r m a l |
— | Chain normalizes to normal |
m a l |
mal |
Full chain matches insult token |
Overlapping matches from both paths are deduplicated before scoring. When spans overlap, the earlier/longer match is kept.
ProfanityMatch::start() and length() are byte offsets into the original UTF-8 input. Masking uses these spans directly; there is no proportional or approximate mapping.
Severity weights:
| Severity | Score contribution |
|---|---|
clean |
0 |
low |
10 |
medium |
25 |
high |
50 |
The final score is the sum of all unique match severities.
AnalysisResult::severity() returns the highest severity among matched terms. If there are no matches, severity is clean.
hasProfanity(): boolscore(): intseverity(): stringmatches(): arraymasked(string $mask = '*'): string
original(): string— matched substring from original inputnormalized(): string— normalized form used for lookupterm(): string— canonical dictionary termlanguage(): string— profile codecategory(): stringseverity(): stringstart(): int— byte offset in original UTF-8 textlength(): int— byte length in original UTF-8 text
- Seed dictionary —
data/tr.phpis minimal and for testing; production use requires curated dictionaries. - Short terms — very short dictionary entries (e.g.
mal,aq) can match in legitimate phrases when they appear as standalone tokens. intloptional — NFC normalization requiresext-intl; without it, visually equivalent Unicode forms may not match.- Single-pass analysis — no context-aware NLP; each token/chain is evaluated independently.
- New normalization stages belong in the global pipeline unless language-specific.
- Dictionary author rows remain plain PHP arrays with
term,category, andseverity. - Derived fields such as
normalizedare produced at build time viaDictionary::fromRows(). - Additional severity levels or scoring policies require explicit interfaces in future versions.
- Framework adapters should live in separate packages depending on this core library.
- Matcher changes are bug-fix only while frozen; see
FOUNDATION.md.