rules

package
v0.0.0-...-f49dfd9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 6 Imported by: 0

README

rules

A small pattern language for matching short texts such as job titles, and two indexes that match it at scale. A rule matches one text; a set of rules decides whether a text belongs to something.

import (
	"github.com/Back-to-code/f2f-match-utils/rules"
	"github.com/Back-to-code/f2f-match-utils/rules/ruleindex"
	"github.com/Back-to-code/f2f-match-utils/rules/textindex"
)
package for
rules parsing, validation with readable errors, Explain, normalisation, matching one set against one text
rules/textindex a few dozen rules at a time against up to millions of stored texts
rules/ruleindex a few texts at a time against up to millions of stored rules

Both indexes give exactly the results of evaluating every rule on every text with rules.Compiled; they only get there faster.

The syntax

A set has includes (rules) and excludes (terms). A text matches a set when one of its includes matches the text and none of its excludes does.

{
  "includes": ["(magazijn|warehouse)+medewerker~", "orderpicker~", "logistiek* -manager~"],
  "excludes": ["*manager", "stage*"]
}
Normalisation

Texts and the words in rules are normalised the same way, by the fold of the sanitize package:

  1. Lowercase and remove diacritics: é → e, ë → e, ß → ss, ø → o, æ → ae, œ → oe, ł → l, đ → d, ð → d, ħ → h, þ → th.
  2. Drop . and apostrophes (', ’, ‘, `, ´): o.a. → oa, it's → its.
  3. Every other character that is not a-z0-9 separates words.
  4. Joined pairs: every two adjacent words are also added as one word. magazijn medewerker and magazijn-medewerker both give the words magazijn, medewerker and magazijnmedewerker.

A text is then a set of words. Word order only matters through the joined pairs. rules.Words returns this set.

Grammar
rule     := term (' ' term)*          every positive term must match, no '-' term may match
term     := ['-'] alt
alt      := seq ('|' seq)*            any alternative
seq      := unit ('+' unit)*          compound: one word, or its parts as separate words
unit     := '(' alt ')' | literal
literal  := ['*'] letters ['*'] | ['*'] letters '~'
syntax matches a word that example
x equals x kok
x* starts with x logistiek*: logistiek, logistieke, logistiekmedewerker
*x ends with x *monteur: monteur, automonteur
*x* contains x (allowed, rarely what you want) *logist*
x~ is x or an inflection of it medewerker~: medewerkers, medewerkster, …
a|b matches either verkoper~|verkoopster
a+b is the joined form ab, or each part matches some word, in any order magazijn+medewerker: magazijnmedewerker, medewerker magazijn
-term (in a rule) must not match any word medewerker~ -hoofd*
  • ~ stands for the word plus s, e, en, es and n; a word ending in er also takes ster and sters (medewerker → medewerkster), one ending in eur also euse (chauffeur → chauffeuse), one ending in a doubled vowel and a consonant also the single vowel plus e (commercieel → commerciele). rules.Inflections lists them. Irregular forms are written as alternatives.
  • + binds tighter than |: a|b+c is a or b+c. Parts may be groups: (magazijn|warehouse)+medewerker~.
  • Wildcards sit on the outer ends of a term: a leading * on the first part of a compound, a trailing * or ~ on the last part. They attach to words, never to groups.
  • Terms are separated by spaces; there are no spaces inside ( ) or around | and +.
  • An exclude is a single term without spaces and without a leading -.
Validation

rules.ParseRule and rules.ParseExclude return a *rules.SyntaxError listing every problem, worded for whoever wrote the rule:

  • a word may only contain letters and digits after normalisation (e-commerce must be e+commerce or ecommerce);
  • a word with * or ~ needs at least 4 letters (*er is rejected);
  • a rule needs at least one term without -;
  • one term may expand to at most 2000 joined forms.

rules.Explain prints what a rule covers, which helps when writing rules by hand or with a language model.

Using it

One set, one text
set, problems := rules.Compile(rules.Set{
	Includes: []string{"(magazijn|warehouse)+medewerker~", "*er"},
	Excludes: []string{"*manager"},
})
// problems[0]: includes[1] '*er': a word with * or ~ needs at least 4 letters, 'er' has 2

set.Match("Magazijn-medewerkers") // 0: the index in Includes of the first include that matches
set.Match("magazijnmanager")      // -1

Compile always returns a usable set: an invalid entry is left out, and every index keeps pointing into the original lists, so stored attributions stay right.

Redundant includes

Set.PruneIncludes blanks the includes whose matches the other includes already have, and leaves out the alternatives of an include that only bring such matches. The set matches the same texts afterwards.

set := rules.Set{Includes: []string{"*bouw*", "bouw+techniek", "(bouw|installatie)+techniek"}}
pruned, changed := set.PruneIncludes()
// pruned.Includes: ["*bouw*", "", "installatie+techniek"], changed: 2
  • An include is covered when one of its positive terms is. A pattern is covered when every word it matches is matched by a pattern of another include: *x* covers every pattern containing x, x* exact and prefix patterns starting with x, *x exact and suffix patterns ending in x. A literal is covered when all its ~ forms are, a group when all its options are, and a compound when its joined forms and one of its parts are, or when another include is that same compound.
  • Only includes of one term cover others: a second term or a - term narrows a rule, so none of its patterns makes it match on its own.
  • An option of a group inside a compound goes only when the compound with that option is covered as a whole: against bouw*, techniek+(bouw|installatie) stays, since techniekbouw does not start with bouw.
  • Of includes that match the same texts the first stays. Indexes keep pointing into the original lists, as with Compile; Clean drops the blanks.

It reads the rules alone, never texts, so it only prunes what it can prove: an include stays when it takes several others together to cover it.

Many texts: textindex

Texts are stored under an ID of any comparable type, a few per ID if that fits (one ID per person, with a text per position held). Texts with the same words are stored once.

texts := textindex.New[int64]()
texts.Set(42, "Magazijnmedewerker", "Heftruckchauffeur")
texts.Append(42, "Orderpicker")
texts.Delete(7)

for _, hit := range texts.Match(set) {
	// hit.ID: 42, hit.Position: 0, hit.Text: "Magazijnmedewerker",
	// hit.Include: 0, hit.Rule: "(magazijn|warehouse)+medewerker~"
}

Hits are grouped by ID, in the order of the texts' positions. Writes take a write lock; a match holds a read lock while it runs.

Many rules: ruleindex

Sets are stored under an ID of any comparable type.

sets := ruleindex.New[string]()
problems := sets.Set("set-1", rules.Set{Includes: []string{"chauffeur~", "heftruck*"}})
sets.SetAll(allSets) // iter.Seq2[ID, rules.Set], to fill the index in one go
sets.Delete("set-2")

for _, match := range sets.Match("Heftruckchauffeur") {
	// match.ID: "set-1", match.Include: 0, match.Rule: "chauffeur~"
}

Matches come in the order the sets were last written. Matching takes no lock and sees the sets as they were when it started.

Speed

Apple M5, synthetic data from internal/synth: made-up titles whose words follow a power law, and sets of random rules that use the whole syntax. 0.7% of the sets match a text, and a set matches 0.7% of the texts.

size per call index memory build
textindex.Match, a set of 20 includes and 3 excludes 1M texts (635k distinct) 0.6 ms 275 MB 1.7 s
textindex.Set, replace the 3 texts of an ID 1M texts 24 µs
ruleindex.Match, one text 1M rules (20k sets) 22 µs 390 MB 2.3 s
ruleindex.Set, replace a set of 50 includes 1M rules 0.7 ms
Set.PruneIncludes, 71 of 150 includes pruned 150 rules 0.3 ms
go test -run '^$' -bench . -benchmem ./rules ./rules/textindex ./rules/ruleindex

How the indexes work

The syntax is shaped so both directions can be indexed: every term compiles to word patterns of four kinds (exact, prefix, suffix, infix), and a pattern never spans a word boundary, since the joined pairs take care of compounds written apart.

textindex keeps a vocabulary of every word with the texts that have it. Exact patterns are a hash lookup. Prefix and suffix patterns are a range in the words sorted forwards or backwards, and infix patterns read the words that have the pattern's two rarest four-letter grams, which works because wildcard words have at least four letters. A joined pair is left out of a prefix range when its left word is at least as long as the prefix: the text then also has that word, which starts with the prefix too. Each rule is evaluated with bitmaps over the distinct texts, and the excludes and first-match attribution are bitmap operations too. Of the 0.6 ms above, about 0.2 ms is the matching and the rest is collecting the 7k hits, so a set that matches less is faster. Words and postings are updated in place on every write.

ruleindex puts every pattern of every rule in four hash maps. For each word of a text it probes the whole word, its prefixes, its suffixes and its substrings, with a 4-letter bit filter skipping most probes. The result is the set of patterns the text hits. Each rule is listed under the patterns of its most selective term only, so only rules with a hit are evaluated, and evaluating one is bit tests on pattern ids. Identical rules and subterms are stored once across all sets. Pass ruleindex.WithEstimator(texts.Estimate) to rank the terms by how common they are in your own texts instead of by a heuristic.

The rules live in immutable segments. A write builds a small segment for the sets it adds and marks their old versions dead. Segments are merged as they pile up, and merges beyond 50k rules run in the background, so neither writes nor matches wait for them. Compact merges everything into one segment.

The fold table

The fold table lives in the sanitize package.

Documentation

Overview

Package rules parses, validates and explains match rules, a small pattern language for short texts such as job titles, and matches a set of them against one text at a time. The textindex and ruleindex packages match them at scale with the same results. README.md describes the syntax.

Index

Examples

Constants

View Source
const (
	// MinWildcardLetters is the least number of letters a word with * or ~ may have.
	MinWildcardLetters = 4
	// MaxJoinedForms caps the joined forms one term may expand to. They are the cartesian product of a
	// compound's groups; beyond this a rule is a word list in disguise and should be split.
	MaxJoinedForms = 2000
)

Variables

This section is empty.

Functions

func Compile

func Compile(set Set) (*Compiled, []Problem)

Compile parses every include and exclude. The result is always usable: the problems are the entries left out of it, and it is up to the caller whether those make the whole set unacceptable.

Example
package main

import (
	"fmt"

	"github.com/Back-to-code/f2f-match-utils/rules"
)

func main() {
	set, problems := rules.Compile(rules.Set{
		Includes: []string{"(magazijn|warehouse)+medewerker~", "*er"},
		Excludes: []string{"*manager"},
	})
	for _, problem := range problems {
		fmt.Println(problem)
	}
	fmt.Println(set.Match("Magazijn-medewerkers"))
	fmt.Println(set.Match("magazijnmanager"))
}
Output:
includes[1] '*er': a word with * or ~ needs at least 4 letters, 'er' has 2
0
-1

func Explain

func Explain(rule string) string

Explain describes in plain text what a rule covers: per term its alternatives, the joined and apart forms of compounds and the words every ~ stands for. For an invalid rule it lists the problems.

Example
package main

import (
	"fmt"

	"github.com/Back-to-code/f2f-match-utils/rules"
)

func main() {
	fmt.Println(rules.Explain("magazijn+medewerker~ -hoofd*"))
}
Output:
rule: magazijn+medewerker~ -hoofd*
term 1 must match: magazijn+medewerker~
  one word: magazijnmedewerker~
  or apart, each part some word, any order: magazijn & medewerker~
term 2 must NOT match: hoofd* (prefix)
~ forms (a joined form ending in a ~ word takes the same endings):
  medewerker~ = medewerker, medewerkers, medewerkere, medewerkeren, medewerkeres, medewerkern, medewerkster, medewerksters

func Inflections

func Inflections(word string) []string

Inflections returns the forms `word~` stands for, word itself first.

func Messages

func Messages(err error) []string

Messages returns the problems of a *SyntaxError, or the error's text for any other error.

func Words

func Words(text string) []string

Words returns the word set a rule is matched against: the words of sanitize.SplitWords plus every two adjacent words joined into one, without duplicates, real words first.

Example
package main

import (
	"fmt"

	"github.com/Back-to-code/f2f-match-utils/rules"
)

func main() {
	fmt.Println(rules.Words("Magazijn-medewerker (o.a. heftruck)"))
}
Output:
[magazijn medewerker oa heftruck magazijnmedewerker medewerkeroa oaheftruck]

Types

type Compiled

type Compiled struct {
	Set      Set
	Includes []*Rule
	Excludes []*Term
}

Compiled is a Set with its includes and excludes parsed. An invalid entry is nil, so every index still points into the Set's lists.

func MustCompile

func MustCompile(set Set) *Compiled

MustCompile is Compile for sets known to be valid; it panics on the first problem.

func (*Compiled) Excluded

func (c *Compiled) Excluded(words []string) bool

func (*Compiled) Match

func (c *Compiled) Match(text string) int

Match is MatchWords on the word set of text.

func (*Compiled) MatchWords

func (c *Compiled) MatchWords(words []string) int

MatchWords returns the index in Includes of the first include that matches the word set, or -1 when none does or an exclude matches. words is a word set as returned by Words.

This evaluates every rule on its own, which is fine for a few texts and rules; for many of either, use the textindex or ruleindex packages.

type Form

type Form struct {
	Text  string
	Lead  bool
	Trail bool
	Tilde bool
}

Form is a folded literal, or the joined form of a compound, with its wildcards.

func (Form) Kind

func (f Form) Kind() Kind

func (Form) Patterns

func (f Form) Patterns() []Pattern

Patterns returns one pattern, or one per inflection when the form ends in ~.

func (Form) String

func (f Form) String() string

type Kind

type Kind uint8

Kind is how a word pattern compares against one word of a text.

const (
	Exact Kind = iota
	Prefix
	Suffix
	Infix
)

func (Kind) String

func (k Kind) String() string

type Node

type Node struct {
	Type NodeType
	// Raw, Lead, Trail and Tilde are the literal as written; Form is it folded.
	Raw      string
	Lead     bool
	Trail    bool
	Tilde    bool
	Form     Form
	Children []*Node
	// Forms are a compound's joined forms.
	Forms []Form
	// Patterns are a literal's patterns, or the patterns of a compound's joined forms.
	Patterns []Pattern
}

Node is a literal, a group of alternatives (Children are the options) or a compound (Children are the parts).

func (*Node) MatchWords

func (n *Node) MatchWords(words []string) bool

MatchWords reports whether the node matches a word set as returned by Words.

type NodeType

type NodeType uint8
const (
	LiteralNode NodeType = iota
	GroupNode
	CompoundNode
)

type Normalizer

type Normalizer struct {

	// RealWords is how many of the last Words result are real words; the joined pairs follow them.
	RealWords int
	// contains filtered or unexported fields
}

Normalizer turns texts into word sets. It reuses its buffers between calls, so it is not safe for concurrent use; the zero value is ready to use.

func (*Normalizer) Words

func (n *Normalizer) Words(text string) []string

Words is the package level Words without the copy: the returned slice is only valid until the next call, the strings in it stay valid.

type Pattern

type Pattern struct {
	Kind Kind
	Text string
}

Pattern is tested against a single word of a text's word set.

func (Pattern) Matches

func (p Pattern) Matches(word string) bool

func (Pattern) String

func (p Pattern) String() string

type Problem

type Problem struct {
	Exclude  bool
	Index    int
	Text     string
	Messages []string
}

Problem is an include or exclude of a Set that does not parse.

func (Problem) Error

func (p Problem) Error() string

type Rule

type Rule struct {
	// Source is the rule as written, without surrounding whitespace.
	Source string
	Terms  []*Term
}

Rule matches a text when every positive term matches it and no negative term does.

func MustParseRule

func MustParseRule(rule string) *Rule

func ParseRule

func ParseRule(rule string) (*Rule, error)

ParseRule parses one rule. On failure the error is a *SyntaxError that lists every problem found, worded so it can be shown to whoever wrote the rule.

func (*Rule) Match

func (r *Rule) Match(text string) bool

func (*Rule) MatchWords

func (r *Rule) MatchWords(words []string) bool

MatchWords reports whether the rule matches a word set as returned by Words.

func (*Rule) Negatives

func (r *Rule) Negatives() []*Term

func (*Rule) NormalizedSource

func (r *Rule) NormalizedSource() string

NormalizedSource prints the rule in normalised syntax: folded literals, single spaces between terms and only the parentheses that are needed. Rules that print the same match the same.

func (*Rule) Positives

func (r *Rule) Positives() []*Term

type Set

type Set struct {
	Includes []string `json:"includes"`
	Excludes []string `json:"excludes"`
}

Set is the rules for one kind of text: a text matches when one of the includes matches it and none of the excludes does.

func (Set) Clean

func (s Set) Clean() Set

Clean trims every include and exclude and drops the empty ones. The lists are never nil, so a cleaned set marshals as [] instead of null.

func (Set) PruneIncludes

func (s Set) PruneIncludes() (Set, int)

PruneIncludes returns the set with every include blanked whose matches the other includes already have, and with the alternatives left out of an include that only bring such matches, so it matches the same texts. The int is how many includes it blanked or rewrote.

Every index keeps pointing into the original lists, as with Compile; Clean drops the blanks. A rewritten include comes back in normalised syntax, the others as written. Invalid includes and the excludes are passed through.

It reads the rules alone, never texts, so it only prunes what it can prove: an include stays when it takes several others together to cover it.

Example
package main

import (
	"fmt"

	"github.com/Back-to-code/f2f-match-utils/rules"
)

func main() {
	set := rules.Set{Includes: []string{"*bouw*", "bouw+techniek", "(bouw|installatie)+techniek"}}
	pruned, changed := set.PruneIncludes()
	fmt.Printf("%q %d\n", pruned.Includes, changed)
}
Output:
["*bouw*" "" "installatie+techniek"] 2

type SyntaxError

type SyntaxError struct {
	Input    string
	Messages []string
}

SyntaxError lists every problem found in one rule or exclude.

func (*SyntaxError) Error

func (e *SyntaxError) Error() string

type Term

type Term struct {
	Source   string
	Negative bool
	Node     *Node
}

Term is one space separated part of a rule, or an exclude.

func ParseExclude

func ParseExclude(term string) (*Term, error)

ParseExclude parses an exclude: one term, without spaces and without a leading '-'. On failure the error is a *SyntaxError.

func (*Term) Body

func (t *Term) Body() string

Body is the term in normalised rule syntax, without the leading '-'.

Directories

Path Synopsis
internal
synth
Package synth generates made-up texts and rule sets for tests and benchmarks.
Package synth generates made-up texts and rule sets for tests and benchmarks.
Package ruleindex matches texts against many rule sets at once: the sets are indexed up front, the texts are not.
Package ruleindex matches texts against many rule sets at once: the sets are indexed up front, the texts are not.
Package textindex matches rule sets against many texts at once: the texts are indexed up front, the rule sets are not.
Package textindex matches rule sets against many texts at once: the texts are indexed up front, the rule sets are not.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL